From d60fa03de6edae0667a93ac36be4206e76255a2c Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 26 May 2026 22:07:55 +0530 Subject: 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 --- src/config.rs | 14 ++ src/db.rs | 68 +++++++++ src/git_mirror.rs | 104 ++++++++++++++ src/http_health.rs | 18 +++ src/main.rs | 44 +++++- src/nip46.rs | 397 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 644 insertions(+), 1 deletion(-) create mode 100644 src/nip46.rs 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 { pub servers: ServersConfig, pub storage: StorageConfig, pub signing: Option, + pub nip46: Option, } #[derive(Debug, Deserialize)] @@ -60,11 +61,23 @@ pub struct SigningConfig { pub key_file: PathBuf, } +#[derive(Debug, Deserialize)] +pub struct Nip46Config { + pub relays: Vec, + #[serde(default = "default_signing_timeout")] + pub signing_timeout_secs: u64, +} + +fn default_signing_timeout() -> u64 { + 604800 +} + pub struct ResolvedConfig { pub discovery: DiscoveryConfig, pub servers: ServersConfig, pub storage: StorageConfig, pub signing: Option, + pub nip46: Option, pub npubs: Vec, } @@ -89,6 +102,7 @@ impl ResolvedConfig { servers: app.servers, storage: app.storage, signing: app.signing, + nip46: app.nip46, npubs, }) } 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 { CREATE INDEX IF NOT EXISTS idx_repos_pubkey ON repos(pubkey); CREATE INDEX IF NOT EXISTS idx_server_syncs_repo ON server_syncs(repo_id); CREATE INDEX IF NOT EXISTS idx_seen_events_id ON seen_events(event_id); + + CREATE TABLE IF NOT EXISTS nip46_sessions ( + npub TEXT PRIMARY KEY, + client_secret TEXT NOT NULL, + signer_pubkey TEXT, + connected INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS signing_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + npub TEXT NOT NULL, + repo_identifier TEXT NOT NULL, + state_event_json TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at INTEGER NOT NULL, + signed_at INTEGER, + error TEXT + ); "#, ) .execute(&self.pool) @@ -258,6 +276,56 @@ impl MirrorDb { .await?; Ok(records) } + + pub async fn get_nip46_session(&self, npub: &str) -> Result> { + let result = sqlx::query_as::<_, Nip46SessionRecord>( + "SELECT * FROM nip46_sessions WHERE npub = ?", + ) + .bind(npub) + .fetch_optional(&self.pool) + .await?; + Ok(result) + } + + pub async fn upsert_nip46_session( + &self, + npub: &str, + client_secret: &str, + signer_pubkey: Option<&str>, + connected: bool, + ) -> Result<()> { + sqlx::query( + r#"INSERT INTO nip46_sessions (npub, client_secret, signer_pubkey, connected) + VALUES (?, ?, ?, ?) + ON CONFLICT(npub) DO UPDATE SET client_secret = ?, signer_pubkey = ?, connected = ?"#, + ) + .bind(npub) + .bind(client_secret) + .bind(signer_pubkey) + .bind(connected as i32) + .bind(client_secret) + .bind(signer_pubkey) + .bind(connected as i32) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn get_all_nip46_sessions(&self) -> Result> { + let records = + sqlx::query_as::<_, Nip46SessionRecord>("SELECT * FROM nip46_sessions") + .fetch_all(&self.pool) + .await?; + Ok(records) + } +} + +#[derive(Debug, sqlx::FromRow)] +pub struct Nip46SessionRecord { + pub npub: String, + pub client_secret: String, + pub signer_pubkey: Option, + pub connected: bool, } fn 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 @@ use crate::db::MirrorDb; use crate::discovery::DiscoveredRepo; use crate::health::GraspServer; +use crate::nip46::Nip46Client; use anyhow::{Context, Result}; use git2::RemoteCallbacks; +use nostr_sdk::prelude::*; use std::path::Path; +use std::sync::Arc; pub struct GitMirror { mirror_dir: std::path::PathBuf, @@ -27,6 +30,7 @@ impl GitMirror { db: &MirrorDb, repo: &DiscoveredRepo, target_servers: &[GraspServer], + nip46_client: Option<&Arc>, ) -> Result<()> { if target_servers.is_empty() { tracing::debug!( @@ -43,6 +47,25 @@ impl GitMirror { self.clone_bare(&repo_path, &repo.clone_urls)?; } + let state_event = match self.build_state_event(&repo_path, repo, nip46_client).await { + Ok(Some(event)) => Some(event), + Ok(None) => { + tracing::warn!( + identifier = %repo.identifier, + "could not build state event — push may be rejected by GRASP servers" + ); + None + } + Err(e) => { + tracing::error!( + identifier = %repo.identifier, + error = %e, + "failed to build state event" + ); + None + } + }; + for server in target_servers { let target_url = server.clone_url(&pk_hex, &repo.identifier); @@ -53,6 +76,31 @@ impl GitMirror { "mirroring git data" ); + if let Some(ref event) = state_event { + let relay_url = server.relay_url(); + if let Ok(url) = RelayUrl::parse(&relay_url) { + let urls = vec![url]; + if let Err(e) = nip46_client + .map_or(Ok(()), |_| { + Err(anyhow::anyhow!("need nostr client to send state event")) + }) + { + let _ = e; + } + + let nostr_client = nostr_sdk::Client::default(); + let _ = nostr_client.add_relay(&relay_url).await; + nostr_client.connect().await; + if let Err(e) = nostr_client.send_event_to(urls, event.clone()).await { + tracing::warn!( + server = %server.domain, + error = %e, + "failed to publish state event to server relay" + ); + } + } + } + let repo_id = db.get_all_repos().await.ok().and_then(|repos| { repos .iter() @@ -88,6 +136,62 @@ impl GitMirror { Ok(()) } + async fn build_state_event( + &self, + repo_path: &std::path::PathBuf, + repo: &DiscoveredRepo, + nip46_client: Option<&Arc>, + ) -> Result> { + let nip46 = match nip46_client { + Some(c) => c, + None => return Ok(None), + }; + + let git_repo = git2::Repository::open(repo_path) + .with_context(|| format!("failed to open bare repo at {:?}", repo_path))?; + + let mut tags: Vec = vec![ + Tag::custom(TagKind::Custom("d".into()), [&repo.identifier]), + ]; + + let refs = git_repo.references()?; + for reference in refs { + let reference = reference?; + let name = reference.name().unwrap_or(""); + if name.is_empty() { + continue; + } + if let Some(oid) = reference.target() { + tags.push(Tag::custom( + TagKind::Custom("ref".into()), + [name, &oid.to_string()], + )); + } + } + + let builder = EventBuilder::new(Kind::Custom(30618), "").tags(tags); + let unsigned = builder.build(repo.pubkey); + + match nip46.sign_event(&repo.pubkey, &unsigned).await { + Ok(signed) => { + tracing::info!( + identifier = %repo.identifier, + event_id = %signed.id.to_hex(), + "signed kind:30618 state event via NIP-46" + ); + Ok(Some(signed)) + } + Err(e) => { + tracing::error!( + identifier = %repo.identifier, + error = %e, + "NIP-46 signing failed for state event" + ); + Err(e) + } + } + } + fn clone_bare(&self, repo_path: &Path, clone_urls: &[String]) -> Result<()> { if let Some(parent) = repo_path.parent() { 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 { pub cycle_count: watch::Receiver, pub last_cycle_ok: watch::Receiver, pub db_path: String, + pub nip46_client: Option>, } pub async fn start_health_server(port: u16, state: Arc) -> anyhow::Result<()> { @@ -31,10 +32,27 @@ async fn health_handler(State(state): State>) -> Json { let cycle_count = *state.cycle_count.borrow(); let last_ok = *state.last_cycle_ok.borrow(); + let nip46_sessions = if let Some(ref client) = state.nip46_client { + let statuses = client.get_status().await; + statuses + .into_iter() + .map(|s| { + json!({ + "npub": s.npub, + "connected": s.connected, + "pairing_uri": s.pairing_uri, + }) + }) + .collect::>() + } else { + vec![] + }; + Json(json!({ "status": if last_ok || cycle_count == 0 { "ok" } else { "degraded" }, "uptime_secs": uptime.as_secs(), "cycle_count": cycle_count, "last_cycle_ok": last_ok, + "nip46": nip46_sessions, })) } 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; mod git_mirror; mod health; mod http_health; +mod nip46; mod nostr_mirror; mod signing; @@ -71,14 +72,50 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result< let db = Arc::new(db); let config = Arc::new(config); + let nip46_client = if let Some(ref nip46_cfg) = config.nip46 { + let client = nip46::Nip46Client::new( + nip46_cfg.relays.clone(), + nip46_cfg.signing_timeout_secs, + db.clone(), + ) + .await?; + + client.init_sessions(&config.npubs).await?; + client.start_listener().await; + + for status in client.get_status().await { + tracing::info!( + npub = %status.npub, + connected = status.connected, + "NIP-46 session status" + ); + if !status.connected { + if let Some(uri) = &status.pairing_uri { + tracing::info!( + npub = %status.npub, + uri = %uri, + "Pair this npub by opening the nostrconnect URI in Amber" + ); + } + } + } + + Some(Arc::new(client)) + } else { + tracing::warn!("no [nip46] config — remote signing disabled"); + None + }; + let (cycle_count_tx, cycle_count_rx) = tokio::sync::watch::channel(0u64); let (last_cycle_ok_tx, last_cycle_ok_rx) = tokio::sync::watch::channel(true); + let nip46_status = nip46_client.clone(); let health_state = Arc::new(http_health::HealthState { started_at: std::time::Instant::now(), cycle_count: cycle_count_rx, last_cycle_ok: last_cycle_ok_rx, db_path: config.storage.database.display().to_string(), + nip46_client: nip46_status, }); let health_port = config.storage.health_port; @@ -111,6 +148,8 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result< let mirror = git_mirror::GitMirror::new(&config.storage.mirror_dir); let nostr_mirror = nostr_mirror::NostrMirror::new(nostr_client.clone()); + let nip46_client_ref: Option> = nip46_client.clone(); + let mut interval = tokio::time::interval(std::time::Duration::from_secs( config.discovery.poll_interval_secs, )); @@ -132,6 +171,7 @@ async fn run_daemon(config: config::ResolvedConfig, db: db::MirrorDb) -> Result< &mirror, &nostr_mirror, &healthy, + nip46_client_ref.as_ref(), ).await; match &result { @@ -160,6 +200,7 @@ async fn mirror_cycle( mirror: &git_mirror::GitMirror, nostr_mirror: &nostr_mirror::NostrMirror, servers: &Arc>, + nip46_client: Option<&Arc>, ) -> Result<()> { tracing::info!("starting mirror cycle"); @@ -190,7 +231,7 @@ async fn mirror_cycle( "mirroring to missing servers" ); - if let Err(e) = mirror.mirror_repo_to_servers(db, repo, &missing).await { + if let Err(e) = mirror.mirror_repo_to_servers(db, repo, &missing, nip46_client).await { tracing::error!(identifier = %repo.identifier, error = %e, "git mirror failed for repo, continuing"); } @@ -310,6 +351,7 @@ async fn run_mirror_once(config: config::ResolvedConfig, db: db::MirrorDb) -> Re &mirror, &nostr_mirror, &healthy, + None, ) .await } 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 @@ +use crate::db::MirrorDb; +use anyhow::{Context, Result}; +use nostr::nips::nip04; +use nostr::nips::nip46::{Message, NostrConnectURI, NostrConnectMetadata, Request, ResponseResult}; +use nostr_sdk::prelude::*; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{oneshot, RwLock}; + +struct Session { + npub: PublicKey, + client_keys: Keys, + signer_pubkey: Option, + connected: bool, + pairing_uri: String, +} + +struct PendingRequest { + tx: oneshot::Sender>, + created_at: std::time::Instant, +} + +pub struct Nip46Client { + sessions: Arc>>, + pending: Arc>>, + client: nostr_sdk::Client, + relays: Vec, + signing_timeout: Duration, + db: Arc, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct Nip46Status { + pub npub: String, + pub connected: bool, + pub pairing_uri: Option, +} + +impl Nip46Client { + pub async fn new( + relays: Vec, + signing_timeout_secs: u64, + db: Arc, + ) -> Result { + let client = nostr_sdk::Client::default(); + for url in &relays { + let _ = client.add_relay(url).await; + } + client.connect().await; + + let sessions = Arc::new(RwLock::new(HashMap::new())); + let pending = Arc::new(RwLock::new(HashMap::new())); + + Ok(Self { + sessions, + pending, + client, + relays, + signing_timeout: Duration::from_secs(signing_timeout_secs), + db, + }) + } + + pub async fn init_sessions(&self, npubs: &[PublicKey]) -> Result<()> { + let records = self.db.get_all_nip46_sessions().await?; + let existing: HashMap = records + .into_iter() + .map(|r| (r.npub, r)) + .collect(); + + for pk in npubs { + let pk_hex = pk.to_hex(); + let session = if let Some(rec) = existing.get(&pk_hex) { + let client_keys = + Keys::parse(&rec.client_secret).context("invalid stored client secret")?; + let signer_pk = rec + .signer_pubkey + .as_ref() + .map(|s| PublicKey::from_hex(s)) + .transpose() + .context("invalid stored signer pubkey")?; + + let pairing_uri = Self::build_pairing_uri(&client_keys, &self.relays); + + Session { + npub: *pk, + client_keys, + signer_pubkey: signer_pk, + connected: rec.connected && signer_pk.is_some(), + pairing_uri, + } + } else { + let client_keys = Keys::generate(); + let pairing_uri = Self::build_pairing_uri(&client_keys, &self.relays); + + self.db + .upsert_nip46_session(&pk_hex, &client_keys.secret_key()?.to_hex(), None, false) + .await?; + + Session { + npub: *pk, + client_keys, + signer_pubkey: None, + connected: false, + pairing_uri, + } + }; + + tracing::info!( + npub = %pk_hex, + connected = session.connected, + has_signer = session.signer_pubkey.is_some(), + "NIP-46 session initialized" + ); + + self.sessions.write().await.insert(pk_hex, session); + } + + Ok(()) + } + + pub async fn start_listener(&self) { + let sessions = self.sessions.read().await; + let client_pubkeys: Vec = sessions + .values() + .map(|s| s.client_keys.public_key()) + .collect(); + drop(sessions); + + if client_pubkeys.is_empty() { + tracing::warn!("no NIP-46 sessions to listen for"); + return; + } + + let filter = Filter::new() + .kind(Kind::Custom(24133)) + .pubkeys(client_pubkeys); + + let _ = self + .client + .subscribe(filter, None) + .await + .map_err(|e| tracing::error!(error = %e, "failed to subscribe for NIP-46 responses")); + + let sessions = self.sessions.clone(); + let pending = self.pending.clone(); + let db = self.db.clone(); + let client = self.client.clone(); + + tokio::spawn(async move { + let _ = client + .handle_notifications(|notification| { + let sessions = sessions.clone(); + let pending = pending.clone(); + let db = db.clone(); + + async move { + if let RelayPoolNotification::Event { event, .. } = notification.as_ref() { + if event.kind == Kind::Custom(24133) { + let _ = + Self::handle_response(&sessions, &pending, &db, event).await; + } + } + Ok(false) + } + }) + .await + .map_err(|e| tracing::error!(error = %e, "NIP-46 listener exited")); + }); + + tracing::info!("NIP-46 response listener started"); + } + + async fn handle_response( + sessions: &Arc>>, + pending: &Arc>>, + db: &Arc, + event: &Event, + ) -> Result<()> { + let sessions_guard = sessions.read().await; + + let session = sessions_guard + .values() + .find(|s| s.client_keys.public_key() == event.pubkey); + + let session = match session { + Some(s) => s, + None => return Ok(()), + }; + + let decrypted = nip04::decrypt( + session.client_keys.secret_key()?, + &event.pubkey, + &event.content, + ) + .context("failed to decrypt NIP-46 response")?; + + let message: Message = Message::from_json(&decrypted) + .context("failed to parse NIP-46 message")?; + + match &message { + Message::Response { id, result, error } => { + if let Some(err) = error { + tracing::error!(id = %id, error = %err, "NIP-46 response error"); + let mut pending_guard = pending.write().await; + if let Some(p) = pending_guard.remove(id) { + let _ = p.tx.send(Err(anyhow::anyhow!("signer error: {}", err))); + } + return Ok(()); + } + + if let Some(result) = result { + match result { + ResponseResult::GetPublicKey(pk) => { + let pk_hex = session.npub.to_hex(); + let signer_hex = pk.to_hex(); + + tracing::info!( + npub = %pk_hex, + signer = %signer_hex, + "NIP-46 session connected — learned signer pubkey" + ); + + drop(sessions_guard); + let mut sessions_guard = sessions.write().await; + if let Some(s) = sessions_guard.get_mut(&pk_hex) { + s.signer_pubkey = Some(*pk); + s.connected = true; + } + + let _ = db + .upsert_nip46_session( + &pk_hex, + &session.client_keys.secret_key()?.to_hex(), + Some(&signer_hex), + true, + ) + .await; + } + _ => { + let mut pending_guard = pending.write().await; + if let Some(p) = pending_guard.remove(id) { + let _ = p.tx.send(Ok(result.clone())); + } + } + } + } + } + Message::Request { id, req } => { + tracing::debug!(id = %id, method = %req.method(), "received NIP-46 request from signer"); + } + } + + Ok(()) + } + + pub async fn connect_session(&self, npub: &PublicKey) -> Result<()> { + let sessions = self.sessions.read().await; + let pk_hex = npub.to_hex(); + let session = sessions + .get(&pk_hex) + .context("no NIP-46 session for npub")?; + + let connect_req = Request::Connect { + public_key: *npub, + secret: None, + }; + + self.send_request_to_signer(&session, connect_req).await?; + + tracing::info!(npub = %pk_hex, "sent connect request to signer"); + Ok(()) + } + + pub async fn sign_event(&self, npub: &PublicKey, unsigned: &UnsignedEvent) -> Result { + let sessions = self.sessions.read().await; + let pk_hex = npub.to_hex(); + let session = sessions + .get(&pk_hex) + .context("no NIP-46 session for npub")?; + + if !session.connected || session.signer_pubkey.is_none() { + anyhow::bail!("NIP-46 session not connected — pair with signer first"); + } + + let result = self + .send_request_to_signer(&session, Request::SignEvent(unsigned.clone())) + .await?; + + match result { + ResponseResult::SignEvent(signed) => Ok(*signed), + other => anyhow::bail!("unexpected NIP-46 response: {:?}", other), + } + } + + async fn send_request_to_signer( + &self, + session: &Session, + request: Request, + ) -> Result { + let signer_pubkey = session + .signer_pubkey + .context("no signer pubkey — not paired")?; + + let message = Message::request(request); + let message_json = message.as_json(); + + let encrypted = + nip04::encrypt(session.client_keys.secret_key()?, &signer_pubkey, &message_json)?; + + let event_builder = EventBuilder::new( + Kind::Custom(24133), + &encrypted, + [Tag::public_key(signer_pubkey)], + ); + let event = event_builder.sign_with_keys(&session.client_keys)?; + + let request_id = message.id().to_string(); + + let (tx, rx) = oneshot::channel(); + { + let mut pending = self.pending.write().await; + pending.insert( + request_id.clone(), + PendingRequest { + tx, + created_at: std::time::Instant::now(), + }, + ); + } + + self.client.send_event(event).await?; + + let timeout = self.signing_timeout; + let result = tokio::time::timeout(timeout, async { + match rx.await { + Ok(Ok(response)) => Ok(response), + Ok(Err(e)) => Err(e), + Err(_) => Err(anyhow::anyhow!("signing request channel dropped")), + } + }) + .await + .map_err(|_| { + { + let pending_id = request_id.clone(); + async move { + let mut pending = self.pending.write().await; + pending.remove(&pending_id); + } + } + anyhow::anyhow!( + "NIP-46 signing request timed out after {}s", + timeout.as_secs() + ) + })??; + + Ok(result) + } + + pub async fn get_status(&self) -> Vec { + let sessions = self.sessions.read().await; + sessions + .values() + .map(|s| Nip46Status { + npub: s.npub.to_hex(), + connected: s.connected, + pairing_uri: if !s.connected { + Some(s.pairing_uri.clone()) + } else { + None + }, + }) + .collect() + } + + pub async fn get_pairing_uri(&self, npub: &PublicKey) -> Option { + let sessions = self.sessions.read().await; + sessions + .get(&npub.to_hex()) + .map(|s| s.pairing_uri.clone()) + } + + fn build_pairing_uri(client_keys: &Keys, relays: &[String]) -> String { + let relay_urls: Vec = relays + .iter() + .filter_map(|r| RelayUrl::parse(r).ok()) + .collect(); + + let uri = NostrConnectURI::client( + client_keys.public_key(), + relay_urls, + "grasp-mirror", + ); + uri.to_string() + } +} -- cgit v1.2.3