upleb.uk

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

summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorYour Name <you@example.com>2026-05-26 22:07:55 +0530
committerYour Name <you@example.com>2026-05-26 22:07:55 +0530
commitd60fa03de6edae0667a93ac36be4206e76255a2c (patch)
tree566adbe43f446a1f92a425c439c8c60f52c8ae51 /src
parent7b1b36b8c7e448d1d170c8c6e1f88bb766163fbb (diff)
Add NIP-46 remote signing for kind:30618 state events
- nip46.rs: full NIP-46 client with session management, NIP-04 encrypted relay-based communication, oneshot response awaiting - db.rs: nip46_sessions table, upsert/get methods - config.rs: Nip46Config with relays + signing_timeout_secs - git_mirror.rs: builds unsigned kind:30618 state event from bare repo refs, signs via NIP-46 before push, publishes to target server relay - http_health.rs: exposes NIP-46 session status in health endpoint - main.rs: wires NIP-46 client into daemon startup, passes to mirror_cycle
Diffstat (limited to 'src')
-rw-r--r--src/config.rs14
-rw-r--r--src/db.rs68
-rw-r--r--src/git_mirror.rs104
-rw-r--r--src/http_health.rs18
-rw-r--r--src/main.rs44
-rw-r--r--src/nip46.rs397
6 files changed, 644 insertions, 1 deletions
diff --git a/src/config.rs b/src/config.rs
index 037deb2..4117507 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -9,6 +9,7 @@ pub struct AppConfig {
9 pub servers: ServersConfig, 9 pub servers: ServersConfig,
10 pub storage: StorageConfig, 10 pub storage: StorageConfig,
11 pub signing: Option<SigningConfig>, 11 pub signing: Option<SigningConfig>,
12 pub nip46: Option<Nip46Config>,
12} 13}
13 14
14#[derive(Debug, Deserialize)] 15#[derive(Debug, Deserialize)]
@@ -60,11 +61,23 @@ pub struct SigningConfig {
60 pub key_file: PathBuf, 61 pub key_file: PathBuf,
61} 62}
62 63
64#[derive(Debug, Deserialize)]
65pub struct Nip46Config {
66 pub relays: Vec<String>,
67 #[serde(default = "default_signing_timeout")]
68 pub signing_timeout_secs: u64,
69}
70
71fn default_signing_timeout() -> u64 {
72 604800
73}
74
63pub struct ResolvedConfig { 75pub struct ResolvedConfig {
64 pub discovery: DiscoveryConfig, 76 pub discovery: DiscoveryConfig,
65 pub servers: ServersConfig, 77 pub servers: ServersConfig,
66 pub storage: StorageConfig, 78 pub storage: StorageConfig,
67 pub signing: Option<SigningConfig>, 79 pub signing: Option<SigningConfig>,
80 pub nip46: Option<Nip46Config>,
68 pub npubs: Vec<PublicKey>, 81 pub npubs: Vec<PublicKey>,
69} 82}
70 83
@@ -89,6 +102,7 @@ impl ResolvedConfig {
89 servers: app.servers, 102 servers: app.servers,
90 storage: app.storage, 103 storage: app.storage,
91 signing: app.signing, 104 signing: app.signing,
105 nip46: app.nip46,
92 npubs, 106 npubs,
93 }) 107 })
94 } 108 }
diff --git a/src/db.rs b/src/db.rs
index bb1bf31..777d19f 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -84,6 +84,24 @@ impl MirrorDb {
84 CREATE INDEX IF NOT EXISTS idx_repos_pubkey ON repos(pubkey); 84 CREATE INDEX IF NOT EXISTS idx_repos_pubkey ON repos(pubkey);
85 CREATE INDEX IF NOT EXISTS idx_server_syncs_repo ON server_syncs(repo_id); 85 CREATE INDEX IF NOT EXISTS idx_server_syncs_repo ON server_syncs(repo_id);
86 CREATE INDEX IF NOT EXISTS idx_seen_events_id ON seen_events(event_id); 86 CREATE INDEX IF NOT EXISTS idx_seen_events_id ON seen_events(event_id);
87
88 CREATE TABLE IF NOT EXISTS nip46_sessions (
89 npub TEXT PRIMARY KEY,
90 client_secret TEXT NOT NULL,
91 signer_pubkey TEXT,
92 connected INTEGER NOT NULL DEFAULT 0
93 );
94
95 CREATE TABLE IF NOT EXISTS signing_queue (
96 id INTEGER PRIMARY KEY AUTOINCREMENT,
97 npub TEXT NOT NULL,
98 repo_identifier TEXT NOT NULL,
99 state_event_json TEXT NOT NULL,
100 status TEXT NOT NULL DEFAULT 'pending',
101 created_at INTEGER NOT NULL,
102 signed_at INTEGER,
103 error TEXT
104 );
87 "#, 105 "#,
88 ) 106 )
89 .execute(&self.pool) 107 .execute(&self.pool)
@@ -258,6 +276,56 @@ impl MirrorDb {
258 .await?; 276 .await?;
259 Ok(records) 277 Ok(records)
260 } 278 }
279
280 pub async fn get_nip46_session(&self, npub: &str) -> Result<Option<Nip46SessionRecord>> {
281 let result = sqlx::query_as::<_, Nip46SessionRecord>(
282 "SELECT * FROM nip46_sessions WHERE npub = ?",
283 )
284 .bind(npub)
285 .fetch_optional(&self.pool)
286 .await?;
287 Ok(result)
288 }
289
290 pub async fn upsert_nip46_session(
291 &self,
292 npub: &str,
293 client_secret: &str,
294 signer_pubkey: Option<&str>,
295 connected: bool,
296 ) -> Result<()> {
297 sqlx::query(
298 r#"INSERT INTO nip46_sessions (npub, client_secret, signer_pubkey, connected)
299 VALUES (?, ?, ?, ?)
300 ON CONFLICT(npub) DO UPDATE SET client_secret = ?, signer_pubkey = ?, connected = ?"#,
301 )
302 .bind(npub)
303 .bind(client_secret)
304 .bind(signer_pubkey)
305 .bind(connected as i32)
306 .bind(client_secret)
307 .bind(signer_pubkey)
308 .bind(connected as i32)
309 .execute(&self.pool)
310 .await?;
311 Ok(())
312 }
313
314 pub async fn get_all_nip46_sessions(&self) -> Result<Vec<Nip46SessionRecord>> {
315 let records =
316 sqlx::query_as::<_, Nip46SessionRecord>("SELECT * FROM nip46_sessions")
317 .fetch_all(&self.pool)
318 .await?;
319 Ok(records)
320 }
321}
322
323#[derive(Debug, sqlx::FromRow)]
324pub struct Nip46SessionRecord {
325 pub npub: String,
326 pub client_secret: String,
327 pub signer_pubkey: Option<String>,
328 pub connected: bool,
261} 329}
262 330
263fn chrono_now_secs() -> i64 { 331fn chrono_now_secs() -> i64 {
diff --git a/src/git_mirror.rs b/src/git_mirror.rs
index 6866de3..c486887 100644
--- a/src/git_mirror.rs
+++ b/src/git_mirror.rs
@@ -1,9 +1,12 @@
1use crate::db::MirrorDb; 1use crate::db::MirrorDb;
2use crate::discovery::DiscoveredRepo; 2use crate::discovery::DiscoveredRepo;
3use crate::health::GraspServer; 3use crate::health::GraspServer;
4use crate::nip46::Nip46Client;
4use anyhow::{Context, Result}; 5use anyhow::{Context, Result};
5use git2::RemoteCallbacks; 6use git2::RemoteCallbacks;
7use nostr_sdk::prelude::*;
6use std::path::Path; 8use std::path::Path;
9use std::sync::Arc;
7 10
8pub struct GitMirror { 11pub struct GitMirror {
9 mirror_dir: std::path::PathBuf, 12 mirror_dir: std::path::PathBuf,
@@ -27,6 +30,7 @@ impl GitMirror {
27 db: &MirrorDb, 30 db: &MirrorDb,
28 repo: &DiscoveredRepo, 31 repo: &DiscoveredRepo,
29 target_servers: &[GraspServer], 32 target_servers: &[GraspServer],
33 nip46_client: Option<&Arc<Nip46Client>>,
30 ) -> Result<()> { 34 ) -> Result<()> {
31 if target_servers.is_empty() { 35 if target_servers.is_empty() {
32 tracing::debug!( 36 tracing::debug!(
@@ -43,6 +47,25 @@ impl GitMirror {
43 self.clone_bare(&repo_path, &repo.clone_urls)?; 47 self.clone_bare(&repo_path, &repo.clone_urls)?;
44 } 48 }
45 49
50 let state_event = match self.build_state_event(&repo_path, repo, nip46_client).await {
51 Ok(Some(event)) => Some(event),
52 Ok(None) => {
53 tracing::warn!(
54 identifier = %repo.identifier,
55 "could not build state event — push may be rejected by GRASP servers"
56 );
57 None
58 }
59 Err(e) => {
60 tracing::error!(
61 identifier = %repo.identifier,
62 error = %e,
63 "failed to build state event"
64 );
65 None
66 }
67 };
68
46 for server in target_servers { 69 for server in target_servers {
47 let target_url = server.clone_url(&pk_hex, &repo.identifier); 70 let target_url = server.clone_url(&pk_hex, &repo.identifier);
48 71
@@ -53,6 +76,31 @@ impl GitMirror {
53 "mirroring git data" 76 "mirroring git data"
54 ); 77 );
55 78
79 if let Some(ref event) = state_event {
80 let relay_url = server.relay_url();
81 if let Ok(url) = RelayUrl::parse(&relay_url) {
82 let urls = vec![url];
83 if let Err(e) = nip46_client
84 .map_or(Ok(()), |_| {
85 Err(anyhow::anyhow!("need nostr client to send state event"))
86 })
87 {
88 let _ = e;
89 }
90
91 let nostr_client = nostr_sdk::Client::default();
92 let _ = nostr_client.add_relay(&relay_url).await;
93 nostr_client.connect().await;
94 if let Err(e) = nostr_client.send_event_to(urls, event.clone()).await {
95 tracing::warn!(
96 server = %server.domain,
97 error = %e,
98 "failed to publish state event to server relay"
99 );
100 }
101 }
102 }
103
56 let repo_id = db.get_all_repos().await.ok().and_then(|repos| { 104 let repo_id = db.get_all_repos().await.ok().and_then(|repos| {
57 repos 105 repos
58 .iter() 106 .iter()
@@ -88,6 +136,62 @@ impl GitMirror {
88 Ok(()) 136 Ok(())
89 } 137 }
90 138
139 async fn build_state_event(
140 &self,
141 repo_path: &std::path::PathBuf,
142 repo: &DiscoveredRepo,
143 nip46_client: Option<&Arc<Nip46Client>>,
144 ) -> Result<Option<Event>> {
145 let nip46 = match nip46_client {
146 Some(c) => c,
147 None => return Ok(None),
148 };
149
150 let git_repo = git2::Repository::open(repo_path)
151 .with_context(|| format!("failed to open bare repo at {:?}", repo_path))?;
152
153 let mut tags: Vec<Tag> = vec![
154 Tag::custom(TagKind::Custom("d".into()), [&repo.identifier]),
155 ];
156
157 let refs = git_repo.references()?;
158 for reference in refs {
159 let reference = reference?;
160 let name = reference.name().unwrap_or("");
161 if name.is_empty() {
162 continue;
163 }
164 if let Some(oid) = reference.target() {
165 tags.push(Tag::custom(
166 TagKind::Custom("ref".into()),
167 [name, &oid.to_string()],
168 ));
169 }
170 }
171
172 let builder = EventBuilder::new(Kind::Custom(30618), "").tags(tags);
173 let unsigned = builder.build(repo.pubkey);
174
175 match nip46.sign_event(&repo.pubkey, &unsigned).await {
176 Ok(signed) => {
177 tracing::info!(
178 identifier = %repo.identifier,
179 event_id = %signed.id.to_hex(),
180 "signed kind:30618 state event via NIP-46"
181 );
182 Ok(Some(signed))
183 }
184 Err(e) => {
185 tracing::error!(
186 identifier = %repo.identifier,
187 error = %e,
188 "NIP-46 signing failed for state event"
189 );
190 Err(e)
191 }
192 }
193 }
194
91 fn clone_bare(&self, repo_path: &Path, clone_urls: &[String]) -> Result<()> { 195 fn clone_bare(&self, repo_path: &Path, clone_urls: &[String]) -> Result<()> {
92 if let Some(parent) = repo_path.parent() { 196 if let Some(parent) = repo_path.parent() {
93 std::fs::create_dir_all(parent) 197 std::fs::create_dir_all(parent)
diff --git a/src/http_health.rs b/src/http_health.rs
index 0cdfeb5..d3f5df2 100644
--- a/src/http_health.rs
+++ b/src/http_health.rs
@@ -12,6 +12,7 @@ pub struct HealthState {
12 pub cycle_count: watch::Receiver<u64>, 12 pub cycle_count: watch::Receiver<u64>,
13 pub last_cycle_ok: watch::Receiver<bool>, 13 pub last_cycle_ok: watch::Receiver<bool>,
14 pub db_path: String, 14 pub db_path: String,
15 pub nip46_client: Option<Arc<crate::nip46::Nip46Client>>,
15} 16}
16 17
17pub async fn start_health_server(port: u16, state: Arc<HealthState>) -> anyhow::Result<()> { 18pub async fn start_health_server(port: u16, state: Arc<HealthState>) -> anyhow::Result<()> {
@@ -31,10 +32,27 @@ async fn health_handler(State(state): State<Arc<HealthState>>) -> Json<Value> {
31 let cycle_count = *state.cycle_count.borrow(); 32 let cycle_count = *state.cycle_count.borrow();
32 let last_ok = *state.last_cycle_ok.borrow(); 33 let last_ok = *state.last_cycle_ok.borrow();
33 34
35 let nip46_sessions = if let Some(ref client) = state.nip46_client {
36 let statuses = client.get_status().await;
37 statuses
38 .into_iter()
39 .map(|s| {
40 json!({
41 "npub": s.npub,
42 "connected": s.connected,
43 "pairing_uri": s.pairing_uri,
44 })
45 })
46 .collect::<Vec<_>>()
47 } else {
48 vec![]
49 };
50
34 Json(json!({ 51 Json(json!({
35 "status": if last_ok || cycle_count == 0 { "ok" } else { "degraded" }, 52 "status": if last_ok || cycle_count == 0 { "ok" } else { "degraded" },
36 "uptime_secs": uptime.as_secs(), 53 "uptime_secs": uptime.as_secs(),
37 "cycle_count": cycle_count, 54 "cycle_count": cycle_count,
38 "last_cycle_ok": last_ok, 55 "last_cycle_ok": last_ok,
56 "nip46": nip46_sessions,
39 })) 57 }))
40} 58}
diff --git a/src/main.rs b/src/main.rs
index 3fcd27b..8e1383e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,6 +4,7 @@ mod discovery;
4mod git_mirror; 4mod git_mirror;
5mod health; 5mod health;
6mod http_health; 6mod http_health;
7mod nip46;
7mod nostr_mirror; 8mod nostr_mirror;
8mod signing; 9mod signing;
9 10
@@ -71,14 +72,50 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result<
71 let db = Arc::new(db); 72 let db = Arc::new(db);
72 let config = Arc::new(config); 73 let config = Arc::new(config);
73 74
75 let nip46_client = if let Some(ref nip46_cfg) = config.nip46 {
76 let client = nip46::Nip46Client::new(
77 nip46_cfg.relays.clone(),
78 nip46_cfg.signing_timeout_secs,
79 db.clone(),
80 )
81 .await?;
82
83 client.init_sessions(&config.npubs).await?;
84 client.start_listener().await;
85
86 for status in client.get_status().await {
87 tracing::info!(
88 npub = %status.npub,
89 connected = status.connected,
90 "NIP-46 session status"
91 );
92 if !status.connected {
93 if let Some(uri) = &status.pairing_uri {
94 tracing::info!(
95 npub = %status.npub,
96 uri = %uri,
97 "Pair this npub by opening the nostrconnect URI in Amber"
98 );
99 }
100 }
101 }
102
103 Some(Arc::new(client))
104 } else {
105 tracing::warn!("no [nip46] config — remote signing disabled");
106 None
107 };
108
74 let (cycle_count_tx, cycle_count_rx) = tokio::sync::watch::channel(0u64); 109 let (cycle_count_tx, cycle_count_rx) = tokio::sync::watch::channel(0u64);
75 let (last_cycle_ok_tx, last_cycle_ok_rx) = tokio::sync::watch::channel(true); 110 let (last_cycle_ok_tx, last_cycle_ok_rx) = tokio::sync::watch::channel(true);
76 111
112 let nip46_status = nip46_client.clone();
77 let health_state = Arc::new(http_health::HealthState { 113 let health_state = Arc::new(http_health::HealthState {
78 started_at: std::time::Instant::now(), 114 started_at: std::time::Instant::now(),
79 cycle_count: cycle_count_rx, 115 cycle_count: cycle_count_rx,
80 last_cycle_ok: last_cycle_ok_rx, 116 last_cycle_ok: last_cycle_ok_rx,
81 db_path: config.storage.database.display().to_string(), 117 db_path: config.storage.database.display().to_string(),
118 nip46_client: nip46_status,
82 }); 119 });
83 120
84 let health_port = config.storage.health_port; 121 let health_port = config.storage.health_port;
@@ -111,6 +148,8 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result<
111 let mirror = git_mirror::GitMirror::new(&config.storage.mirror_dir); 148 let mirror = git_mirror::GitMirror::new(&config.storage.mirror_dir);
112 let nostr_mirror = nostr_mirror::NostrMirror::new(nostr_client.clone()); 149 let nostr_mirror = nostr_mirror::NostrMirror::new(nostr_client.clone());
113 150
151 let nip46_client_ref: Option<Arc<nip46::Nip46Client>> = nip46_client.clone();
152
114 let mut interval = tokio::time::interval(std::time::Duration::from_secs( 153 let mut interval = tokio::time::interval(std::time::Duration::from_secs(
115 config.discovery.poll_interval_secs, 154 config.discovery.poll_interval_secs,
116 )); 155 ));
@@ -132,6 +171,7 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result<
132 &mirror, 171 &mirror,
133 &nostr_mirror, 172 &nostr_mirror,
134 &healthy, 173 &healthy,
174 nip46_client_ref.as_ref(),
135 ).await; 175 ).await;
136 176
137 match &result { 177 match &result {
@@ -160,6 +200,7 @@ async fn mirror_cycle(
160 mirror: &git_mirror::GitMirror, 200 mirror: &git_mirror::GitMirror,
161 nostr_mirror: &nostr_mirror::NostrMirror, 201 nostr_mirror: &nostr_mirror::NostrMirror,
162 servers: &Arc<Vec<health::GraspServer>>, 202 servers: &Arc<Vec<health::GraspServer>>,
203 nip46_client: Option<&Arc<nip46::Nip46Client>>,
163) -> Result<()> { 204) -> Result<()> {
164 tracing::info!("starting mirror cycle"); 205 tracing::info!("starting mirror cycle");
165 206
@@ -190,7 +231,7 @@ async fn mirror_cycle(
190 "mirroring to missing servers" 231 "mirroring to missing servers"
191 ); 232 );
192 233
193 if let Err(e) = mirror.mirror_repo_to_servers(db, repo, &missing).await { 234 if let Err(e) = mirror.mirror_repo_to_servers(db, repo, &missing, nip46_client).await {
194 tracing::error!(identifier = %repo.identifier, error = %e, "git mirror failed for repo, continuing"); 235 tracing::error!(identifier = %repo.identifier, error = %e, "git mirror failed for repo, continuing");
195 } 236 }
196 237
@@ -310,6 +351,7 @@ async fn run_mirror_once(config: config::ResolvedConfig, db: db::MirrorDb) -> Re
310 &mirror, 351 &mirror,
311 &nostr_mirror, 352 &nostr_mirror,
312 &healthy, 353 &healthy,
354 None,
313 ) 355 )
314 .await 356 .await
315} 357}
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}