app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit ef444dc4d68396ccc6ff31429932ecf10b68b5c9
parent ff89b169ee481ac142720d720f63dc200558c562
Author: triesap <tyson@radroots.org>
Date:   Thu,  2 Apr 2026 19:31:49 +0000

remote-signer: gate macos sessions by approved capability

Diffstat:
Mcrates/android/src/remote_signer.rs | 1+
Mcrates/core/src/lib.rs | 16++++++++++++++++
Mcrates/desktop/src/main.rs | 12++++++++++++
Mcrates/desktop/src/remote_signer.rs | 28+++++++++++++++++++++++++++-
Mcrates/ios/src/remote_signer.rs | 1+
Mcrates/remote-signer/src/controller.rs | 56++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/remote-signer/src/protocol.rs | 465+++++++++++++++++++++++++++++++++++++++++++++++--------------------------------
Mcrates/remote-signer/src/session.rs | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
8 files changed, 480 insertions(+), 202 deletions(-)

diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -300,6 +300,7 @@ fn activate_remote_session( client_account_id, approved.user_identity.clone(), approved.relays.clone(), + approved.approved_permissions.clone(), ) .ok_or_else(|| { "pending remote signer session disappeared before activation".to_owned() diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs @@ -144,6 +144,9 @@ pub trait RadrootsAppBackend { fn remote_signer_note_action_state(&self) -> Option<SetupActionState> { None } + fn selected_remote_signer_approved_permissions(&self) -> Option<Vec<String>> { + None + } fn request_remote_signer_note_action(&self, _content: &str) -> Result<(), String> { Ok(()) } @@ -1029,6 +1032,19 @@ impl RadrootsApp { } ui.add_space(16.0); ui.label("Remote signer note"); + if let Some(permissions) = + self.backend.selected_remote_signer_approved_permissions() + { + ui.add_space(8.0); + if permissions.is_empty() { + ui.label("Approved permissions: none"); + } else { + ui.label("Approved permissions"); + for permission in permissions { + ui.monospace(permission); + } + } + } ui.add_space(8.0); ui.add( egui::TextEdit::multiline(&mut *self.remote_signer_note_input) diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -733,6 +733,18 @@ impl RadrootsAppBackend for DesktopBackend { } } + fn selected_remote_signer_approved_permissions(&self) -> Option<Vec<String>> { + #[cfg(target_os = "macos")] + { + return remote_signer::selected_approved_permission_labels().unwrap_or(None); + } + + #[cfg(not(target_os = "macos"))] + { + None + } + } + fn request_remote_signer_note_action(&self, content: &str) -> Result<(), String> { #[cfg(target_os = "macos")] { diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs @@ -172,12 +172,26 @@ impl DesktopRemoteSigner { } pub(crate) fn note_action_state(&self) -> Result<SetupActionState, String> { - if !selected_remote_signer_account()?.is_some() { + let Some(account_id) = selected_remote_signer_account()? else { return Ok(SetupActionState { label: "Sign Remote Kind 1 Note".to_owned(), enabled: false, pending: false, }); + }; + let Some(record) = active_session_for_account_id(account_id.as_str())? else { + return Ok(SetupActionState { + label: "Sign Remote Kind 1 Note".to_owned(), + enabled: false, + pending: false, + }); + }; + if !record.allows_sign_event_kind1() { + return Ok(SetupActionState { + label: "Remote Signer Missing sign_event:kind:1".to_owned(), + enabled: false, + pending: false, + }); } Ok(match self.action_controller.state() { @@ -299,6 +313,7 @@ fn activate_remote_session( client_account_id, approved.user_identity.clone(), approved.relays.clone(), + approved.approved_permissions.clone(), ) .ok_or_else(|| { "pending remote signer session disappeared before activation".to_owned() @@ -360,6 +375,9 @@ impl RadrootsAppRemoteSignerActionControllerHooks for DesktopRemoteSignerHooks { let Some(record) = active_session_for_account_id(account_id.as_str())? else { return Ok(None); }; + if !record.allows_sign_event_kind1() { + return Err("remote signer has not approved sign_event:kind:1".to_owned()); + } let secret = load_client_secret(record.client_account_id())?; Ok(Some((record, secret))) } @@ -392,6 +410,14 @@ fn active_session_for_account_id( Ok(state.active_session_for_account_id(account_id).cloned()) } +pub(crate) fn selected_approved_permission_labels() -> Result<Option<Vec<String>>, String> { + let Some(account_id) = selected_remote_signer_account()? else { + return Ok(None); + }; + Ok(active_session_for_account_id(account_id.as_str())? + .map(|record| record.approved_permission_labels())) +} + fn load_sessions(path: &Path) -> Result<RadrootsAppRemoteSignerSessionStoreState, String> { RadrootsAppRemoteSignerSessionStoreState::load(path).map_err(|error| error.to_string()) } diff --git a/crates/ios/src/remote_signer.rs b/crates/ios/src/remote_signer.rs @@ -298,6 +298,7 @@ fn activate_remote_session( client_account_id, approved.user_identity.clone(), approved.relays.clone(), + approved.approved_permissions.clone(), ) .ok_or_else(|| { "pending remote signer session disappeared before activation".to_owned() diff --git a/crates/remote-signer/src/controller.rs b/crates/remote-signer/src/controller.rs @@ -1,8 +1,9 @@ use crate::protocol::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome, - RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerProgressUpdate, - radroots_app_remote_signer_connect_pending, - radroots_app_remote_signer_poll_pending_session_with_progress, + RadrootsAppRemoteSignerPendingPoller, RadrootsAppRemoteSignerPendingSession, + RadrootsAppRemoteSignerProgressUpdate, radroots_app_remote_signer_connect_pending, + radroots_app_remote_signer_open_pending_poller, + radroots_app_remote_signer_poll_pending_poller_with_progress, }; use crate::session::RadrootsAppRemoteSignerSessionRecord; use std::marker::PhantomData; @@ -105,7 +106,7 @@ where Self::new_with_ops( hooks, Arc::new(default_connect_pending), - Arc::new(default_poll_pending), + default_poll_pending(), Arc::new(std::thread::sleep), ) } @@ -366,17 +367,44 @@ fn default_connect_pending(input: &str) -> Result<RadrootsAppRemoteSignerPending radroots_app_remote_signer_connect_pending(input).map_err(|error| error.to_string()) } -fn default_poll_pending( - record: &RadrootsAppRemoteSignerSessionRecord, - client_secret_key_hex: &str, - progress: Arc<dyn Fn(RadrootsAppRemoteSignerProgressUpdate) + Send + Sync>, -) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, String> { - radroots_app_remote_signer_poll_pending_session_with_progress( - record, - client_secret_key_hex, - move |update| progress(update), +fn default_poll_pending() -> RadrootsAppRemoteSignerPollPendingFn { + let cache: Arc<Mutex<Option<(String, RadrootsAppRemoteSignerPendingPoller)>>> = + Arc::new(Mutex::new(None)); + Arc::new( + move |record, client_secret_key_hex, progress| -> Result<_, String> { + let client_account_id = record.client_account_id().to_owned(); + let mut cache = cache + .lock() + .map_err(|_| "pending poller cache lock poisoned".to_owned())?; + let poller = match cache.as_mut() { + Some((cached_account_id, poller)) if *cached_account_id == client_account_id => { + poller + } + _ => { + let poller = radroots_app_remote_signer_open_pending_poller( + record, + client_secret_key_hex, + ) + .map_err(|error| error.to_string())?; + *cache = Some((client_account_id.clone(), poller)); + &mut cache.as_mut().expect("cache initialized").1 + } + }; + let outcome = radroots_app_remote_signer_poll_pending_poller_with_progress( + poller, + &mut |update| progress(update), + ) + .map_err(|error| error.to_string())?; + if !matches!( + outcome, + RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval + | RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. } + ) { + *cache = None; + } + Ok(outcome) + }, ) - .map_err(|error| error.to_string()) } #[cfg(test)] diff --git a/crates/remote-signer/src/protocol.rs b/crates/remote-signer/src/protocol.rs @@ -14,7 +14,8 @@ use radroots_nostr::prelude::{ use radroots_nostr_connect::message::RADROOTS_NOSTR_CONNECT_RPC_KIND; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectMethod, RadrootsNostrConnectPendingConnectionPollOutcome, - RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, + RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, RadrootsNostrConnectResponseEnvelope, }; use std::sync::atomic::{AtomicU64, Ordering}; @@ -24,7 +25,7 @@ use tokio::sync::broadcast; use tokio::time::timeout; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); -const GET_PUBLIC_KEY_TIMEOUT: Duration = Duration::from_secs(60); +const GET_SESSION_CAPABILITY_TIMEOUT: Duration = Duration::from_secs(60); const SWITCH_RELAYS_TIMEOUT: Duration = Duration::from_secs(30); const SIGN_EVENT_TIMEOUT: Duration = Duration::from_secs(60); static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1); @@ -39,6 +40,7 @@ pub struct RadrootsAppRemoteSignerPendingSession { pub struct RadrootsAppRemoteSignerApprovedSession { pub user_identity: RadrootsIdentityPublic, pub relays: Vec<String>, + pub approved_permissions: RadrootsNostrConnectPermissions, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -62,6 +64,18 @@ pub enum RadrootsAppRemoteSignerPendingPollOutcome { FatalError { message: String }, } +pub(crate) struct RadrootsAppRemoteSignerPendingPoller { + client: ConnectedRemoteSignerSessionClient, +} + +struct ConnectedRemoteSignerSessionClient { + runtime: tokio::runtime::Runtime, + client_identity: RadrootsIdentity, + target: RadrootsAppRemoteSignerTarget, + client: RadrootsNostrClient, + notifications: broadcast::Receiver<RadrootsNostrRelayPoolNotification>, +} + pub fn radroots_app_remote_signer_connect_pending( input: &str, ) -> Result<RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerError> { @@ -92,15 +106,29 @@ pub fn radroots_app_remote_signer_poll_pending_session_with_progress<F>( where F: FnMut(RadrootsAppRemoteSignerProgressUpdate), { - let runtime = Builder::new_current_thread() - .enable_all() - .build() - .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; - runtime.block_on(poll_pending_session( - record, - client_secret_key_hex, - &mut progress, - )) + let mut poller = radroots_app_remote_signer_open_pending_poller(record, client_secret_key_hex)?; + radroots_app_remote_signer_poll_pending_poller_with_progress(&mut poller, &mut progress) +} + +pub(crate) fn radroots_app_remote_signer_open_pending_poller( + record: &RadrootsAppRemoteSignerSessionRecord, + client_secret_key_hex: &str, +) -> Result<RadrootsAppRemoteSignerPendingPoller, RadrootsAppRemoteSignerError> { + let client_identity = load_client_identity(client_secret_key_hex)?; + let target = target_for_record(record); + Ok(RadrootsAppRemoteSignerPendingPoller { + client: ConnectedRemoteSignerSessionClient::connect(client_identity, target)?, + }) +} + +pub(crate) fn radroots_app_remote_signer_poll_pending_poller_with_progress<F>( + poller: &mut RadrootsAppRemoteSignerPendingPoller, + progress: &mut F, +) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> +where + F: FnMut(RadrootsAppRemoteSignerProgressUpdate), +{ + poller.poll_with_progress(progress) } pub fn radroots_app_remote_signer_sign_kind1_note( @@ -216,41 +244,6 @@ fn connect_request_for_target( }) } -async fn poll_pending_session<F>( - record: &RadrootsAppRemoteSignerSessionRecord, - client_secret_key_hex: &str, - progress: &mut F, -) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> -where - F: FnMut(RadrootsAppRemoteSignerProgressUpdate), -{ - let client_identity = load_client_identity(client_secret_key_hex)?; - let mut target = target_for_record(record); - - match execute_request_with_progress( - &client_identity, - &target, - RadrootsNostrConnectMethod::GetPublicKey, - RadrootsNostrConnectRequest::GetPublicKey, - GET_PUBLIC_KEY_TIMEOUT, - progress, - ) - .await - { - Ok(RadrootsNostrConnectResponse::UserPublicKey(public_key)) => { - target.relays = sync_relays(&client_identity, &target, progress).await?; - Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved( - RadrootsAppRemoteSignerApprovedSession { - user_identity: RadrootsIdentityPublic::new(public_key), - relays: target.relays, - }, - )) - } - Ok(response) => Ok(classify_pending_poll_response(response)), - Err(error) => Ok(classify_pending_poll_error(error)), - } -} - async fn sign_kind1_note<F>( record: &RadrootsAppRemoteSignerSessionRecord, client_secret_key_hex: &str, @@ -260,6 +253,11 @@ async fn sign_kind1_note<F>( where F: FnMut(RadrootsAppRemoteSignerProgressUpdate), { + if !record.allows_sign_event_kind1() { + return Err(RadrootsAppRemoteSignerError::ConnectFailed( + "remote signer has not approved sign_event:kind:1".to_owned(), + )); + } let user_identity = record.user_identity.as_ref().ok_or_else(|| { RadrootsAppRemoteSignerError::ConnectFailed( "remote signer session is missing the approved user identity".to_owned(), @@ -280,24 +278,22 @@ where F: FnMut(RadrootsAppRemoteSignerProgressUpdate), { let client_identity = load_client_identity(client_secret_key_hex)?; - let mut target = target_for_record(record); - target.relays = sync_relays(&client_identity, &target, progress).await?; - let response = execute_request_with_progress( - &client_identity, - &target, + let target = target_for_record(record); + let mut client = ConnectedRemoteSignerSessionClient::connect(client_identity, target)?; + let relays = client.sync_relays_if_allowed(record, progress)?; + let response = client.execute_request_with_progress( RadrootsNostrConnectMethod::SignEvent, RadrootsNostrConnectRequest::SignEvent(unsigned_event), SIGN_EVENT_TIMEOUT, progress, - ) - .await?; + )?; match response { RadrootsNostrConnectResponse::SignedEvent(event) => { Ok(RadrootsAppRemoteSignerSignedEvent { event_id_hex: event.id.to_hex(), event_json: event.as_json(), - relays: target.relays, + relays, }) } RadrootsNostrConnectResponse::Error { error, .. } => { @@ -310,41 +306,6 @@ where } } -async fn sync_relays<F>( - client_identity: &RadrootsIdentity, - target: &RadrootsAppRemoteSignerTarget, - progress: &mut F, -) -> Result<Vec<String>, RadrootsAppRemoteSignerError> -where - F: FnMut(RadrootsAppRemoteSignerProgressUpdate), -{ - let response = execute_request_with_progress( - client_identity, - target, - RadrootsNostrConnectMethod::SwitchRelays, - RadrootsNostrConnectRequest::SwitchRelays, - SWITCH_RELAYS_TIMEOUT, - progress, - ) - .await?; - - match response { - RadrootsNostrConnectResponse::RelayList(relays) => { - Ok(relays.into_iter().map(|relay| relay.to_string()).collect()) - } - RadrootsNostrConnectResponse::RelayListUnchanged => Ok(target.relays.clone()), - RadrootsNostrConnectResponse::Error { error, .. } => { - Err(RadrootsAppRemoteSignerError::ConnectFailed(format!( - "remote signer rejected relay update: {error}" - ))) - } - other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse { - method: RadrootsNostrConnectMethod::SwitchRelays, - response: format!("{other:?}"), - }), - } -} - async fn execute_request( client_identity: &RadrootsIdentity, target: &RadrootsAppRemoteSignerTarget, @@ -352,105 +313,176 @@ async fn execute_request( request: RadrootsNostrConnectRequest, request_timeout: Duration, ) -> Result<RadrootsNostrConnectResponse, RadrootsAppRemoteSignerError> { - execute_request_with_progress( - client_identity, - target, - method, - request, - request_timeout, - &mut |_| {}, - ) - .await + let mut client = + ConnectedRemoteSignerSessionClient::connect(client_identity.clone(), target.clone())?; + client.execute_request_with_progress(method, request, request_timeout, &mut |_| {}) } -async fn execute_request_with_progress<F>( - client_identity: &RadrootsIdentity, - target: &RadrootsAppRemoteSignerTarget, - method: RadrootsNostrConnectMethod, - request: RadrootsNostrConnectRequest, - request_timeout: Duration, - progress: &mut F, -) -> Result<RadrootsNostrConnectResponse, RadrootsAppRemoteSignerError> -where - F: FnMut(RadrootsAppRemoteSignerProgressUpdate), -{ - let client = RadrootsNostrClient::from_identity(client_identity); - for relay in &target.relays { - client - .add_relay(relay) - .await +impl RadrootsAppRemoteSignerPendingPoller { + fn poll_with_progress<F>( + &mut self, + progress: &mut F, + ) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> + where + F: FnMut(RadrootsAppRemoteSignerProgressUpdate), + { + match self.client.execute_request_with_progress( + RadrootsNostrConnectMethod::GetSessionCapability, + RadrootsNostrConnectRequest::GetSessionCapability, + GET_SESSION_CAPABILITY_TIMEOUT, + progress, + ) { + Ok(response) => Ok(classify_pending_poll_response(response)), + Err(error) => Ok(classify_pending_poll_error(error)), + } + } +} + +impl ConnectedRemoteSignerSessionClient { + fn connect( + client_identity: RadrootsIdentity, + target: RadrootsAppRemoteSignerTarget, + ) -> Result<Self, RadrootsAppRemoteSignerError> { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + let client = RadrootsNostrClient::from_identity(&client_identity); + let notifications = runtime.block_on(async { + for relay in &target.relays { + client.add_relay(relay).await.map_err(|error| { + RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()) + })?; + } + client.connect().await; + let filter = radroots_nostr_filter_tag( + RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)) + .since(RadrootsNostrTimestamp::now()), + "p", + vec![client_identity.public_key_hex()], + ) .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + let notifications = client.notifications(); + client + .subscribe(filter, None) + .await + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + Ok::<_, RadrootsAppRemoteSignerError>(notifications) + })?; + + Ok(Self { + runtime, + client_identity, + target, + client, + notifications, + }) } - client.connect().await; - - let filter = radroots_nostr_filter_tag( - RadrootsNostrFilter::new() - .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND)) - .since(RadrootsNostrTimestamp::now()), - "p", - vec![client_identity.public_key_hex()], - ) - .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; - let mut notifications = client.notifications(); - client - .subscribe(filter, None) - .await - .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; - let request_id = next_request_id(method.to_string().as_str()); - let event_builder = build_request_event( - client_identity, - &target.signer_identity, - request_id.as_str(), - request.clone(), - )?; - client - .send_event_builder(event_builder) - .await - .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + fn sync_relays_if_allowed<F>( + &mut self, + record: &RadrootsAppRemoteSignerSessionRecord, + progress: &mut F, + ) -> Result<Vec<String>, RadrootsAppRemoteSignerError> + where + F: FnMut(RadrootsAppRemoteSignerProgressUpdate), + { + if !record.allows_switch_relays() { + return Ok(self.target.relays.clone()); + } - let response_method = method.clone(); - let response = timeout(request_timeout, async { - loop { - let notification = match notifications.recv().await { - Ok(notification) => notification, - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => { - return Err(RadrootsAppRemoteSignerError::ConnectFailed( - "remote signer notification stream closed".to_owned(), - )); - } - }; - let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification else { - continue; - }; - let event = *event; - if event.kind != RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { - continue; + match self.execute_request_with_progress( + RadrootsNostrConnectMethod::SwitchRelays, + RadrootsNostrConnectRequest::SwitchRelays, + SWITCH_RELAYS_TIMEOUT, + progress, + )? { + RadrootsNostrConnectResponse::RelayList(relays) => { + let relays: Vec<String> = + relays.into_iter().map(|relay| relay.to_string()).collect(); + self.target.relays = relays.clone(); + Ok(relays) } - if event.pubkey.to_hex() != target.signer_identity.public_key_hex { - continue; - } - match parse_response_event( - client_identity, - &event, - &response_method, - request_id.as_str(), - )? { - Some(RadrootsNostrConnectResponse::AuthUrl(url)) => { - progress(RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { url }); - } - Some(response) => return Ok(response), - None => continue, + RadrootsNostrConnectResponse::RelayListUnchanged => Ok(self.target.relays.clone()), + RadrootsNostrConnectResponse::Error { error, .. } => { + Err(RadrootsAppRemoteSignerError::ConnectFailed(format!( + "remote signer rejected relay update: {error}" + ))) } + other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse { + method: RadrootsNostrConnectMethod::SwitchRelays, + response: format!("{other:?}"), + }), } - }) - .await - .map_err(|_| RadrootsAppRemoteSignerError::RequestTimedOut { - method: method.clone(), - })??; + } - Ok(response) + fn execute_request_with_progress<F>( + &mut self, + method: RadrootsNostrConnectMethod, + request: RadrootsNostrConnectRequest, + request_timeout: Duration, + progress: &mut F, + ) -> Result<RadrootsNostrConnectResponse, RadrootsAppRemoteSignerError> + where + F: FnMut(RadrootsAppRemoteSignerProgressUpdate), + { + let request_id = next_request_id(method.to_string().as_str()); + let response_method = method.clone(); + self.runtime.block_on(async { + let event_builder = build_request_event( + &self.client_identity, + &self.target.signer_identity, + request_id.as_str(), + request, + )?; + self.client + .send_event_builder(event_builder) + .await + .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?; + + timeout(request_timeout, async { + loop { + let notification = match self.notifications.recv().await { + Ok(notification) => notification, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(RadrootsAppRemoteSignerError::ConnectFailed( + "remote signer notification stream closed".to_owned(), + )); + } + }; + let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification + else { + continue; + }; + let event = *event; + if event.kind != RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { + continue; + } + if event.pubkey.to_hex() != self.target.signer_identity.public_key_hex { + continue; + } + match parse_response_event( + &self.client_identity, + &event, + &response_method, + request_id.as_str(), + )? { + Some(RadrootsNostrConnectResponse::AuthUrl(url)) => { + progress(RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { url }); + } + Some(response) => return Ok(response), + None => continue, + } + } + }) + .await + .map_err(|_| RadrootsAppRemoteSignerError::RequestTimedOut { + method: response_method.clone(), + })? + }) + } } fn build_request_event( @@ -523,6 +555,20 @@ fn classify_pending_poll_response( RadrootsAppRemoteSignerApprovedSession { user_identity: RadrootsIdentityPublic::new(public_key), relays: Vec::new(), + approved_permissions: RadrootsNostrConnectPermissions::default(), + }, + ) + } + RadrootsNostrConnectPendingConnectionPollOutcome::ApprovedCapability(capability) => { + RadrootsAppRemoteSignerPendingPollOutcome::Approved( + RadrootsAppRemoteSignerApprovedSession { + user_identity: RadrootsIdentityPublic::new(capability.user_public_key), + relays: capability + .relays + .into_iter() + .map(|relay| relay.to_string()) + .collect(), + approved_permissions: capability.permissions, }, ) } @@ -594,7 +640,11 @@ fn target_for_record( signer_identity: record.signer_identity.clone(), relays: record.relays.clone(), connect_secret: None, - requested_permissions: crate::radroots_app_remote_signer_requested_permissions(), + requested_permissions: if record.approved_permissions.is_empty() { + crate::radroots_app_remote_signer_requested_permissions() + } else { + record.approved_permissions.clone() + }, } } @@ -604,6 +654,9 @@ mod tests { use crate::radroots_app_remote_signer_preview; use nostr::PublicKey; use radroots_app_test_support::{FIXTURE_ALICE, RELAY_PRIMARY_WSS, fixture_identity}; + use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectPermission, RadrootsNostrConnectRemoteSessionCapability, + }; fn fixture_public_key() -> PublicKey { fixture_identity(&FIXTURE_ALICE) @@ -647,23 +700,38 @@ mod tests { } #[test] - fn get_public_key_success_is_classified_as_approved() { - let outcome = classify_pending_poll_response(RadrootsNostrConnectResponse::UserPublicKey( - fixture_public_key(), - )); + fn session_capability_success_is_classified_as_approved() { + let outcome = + classify_pending_poll_response(RadrootsNostrConnectResponse::RemoteSessionCapability( + RadrootsNostrConnectRemoteSessionCapability { + user_public_key: fixture_public_key(), + relays: vec![nostr::RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay")], + permissions: vec![ + RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:1", + ), + RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::SwitchRelays, + ), + ] + .into(), + }, + )); assert!(matches!( outcome, RadrootsAppRemoteSignerPendingPollOutcome::Approved( - RadrootsAppRemoteSignerApprovedSession { user_identity, .. } + RadrootsAppRemoteSignerApprovedSession { user_identity, approved_permissions, .. } ) if user_identity.public_key_hex == fixture_public_key().to_hex() + && approved_permissions.to_string() == "sign_event:kind:1,switch_relays" )); } #[test] fn timeout_error_is_classified_as_transport_failure() { let outcome = classify_pending_poll_error(RadrootsAppRemoteSignerError::RequestTimedOut { - method: RadrootsNostrConnectMethod::GetPublicKey, + method: RadrootsNostrConnectMethod::GetSessionCapability, }); assert!(matches!( @@ -677,14 +745,14 @@ mod tests { fn unexpected_response_error_is_fatal() { let outcome = classify_pending_poll_error(RadrootsAppRemoteSignerError::UnexpectedResponse { - method: RadrootsNostrConnectMethod::GetPublicKey, + method: RadrootsNostrConnectMethod::GetSessionCapability, response: "failed to decode signer response envelope: bad".to_owned(), }); assert!(matches!( outcome, RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message } - if message.contains("unexpected `get_public_key` response") + if message.contains("unexpected `get_session_capability` response") )); } @@ -718,4 +786,27 @@ mod tests { assert_eq!(signed_event.event_id_hex, "deadbeef"); assert_eq!(signed_event.relays, vec!["ws://localhost:8080".to_owned()]); } + + #[test] + fn target_for_record_uses_approved_permissions_when_available() { + let client_identity = fixture_identity(&FIXTURE_ALICE) + .expect("client") + .to_public(); + let signer_identity = fixture_identity(&FIXTURE_ALICE) + .expect("signer") + .to_public(); + let mut record = RadrootsAppRemoteSignerSessionRecord::pending( + client_identity, + signer_identity, + vec![RELAY_PRIMARY_WSS.to_owned()], + ); + record.approved_permissions = vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::SwitchRelays, + )] + .into(); + + let target = target_for_record(&record); + + assert_eq!(target.requested_permissions.to_string(), "switch_relays"); + } } diff --git a/crates/remote-signer/src/session.rs b/crates/remote-signer/src/session.rs @@ -1,5 +1,8 @@ use crate::error::RadrootsAppRemoteSignerError; use radroots_identity::RadrootsIdentityPublic; +use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, +}; use serde::{Deserialize, Serialize}; use std::io::Write; use std::path::Path; @@ -20,6 +23,8 @@ pub struct RadrootsAppRemoteSignerSessionRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub user_identity: Option<RadrootsIdentityPublic>, pub relays: Vec<String>, + #[serde(default)] + pub approved_permissions: RadrootsNostrConnectPermissions, pub status: RadrootsAppRemoteSignerSessionStatus, pub created_at_unix: u64, pub updated_at_unix: u64, @@ -58,6 +63,7 @@ impl RadrootsAppRemoteSignerSessionRecord { signer_identity, user_identity: None, relays, + approved_permissions: RadrootsNostrConnectPermissions::default(), status: RadrootsAppRemoteSignerSessionStatus::PendingApproval, created_at_unix: now, updated_at_unix: now, @@ -73,6 +79,41 @@ impl RadrootsAppRemoteSignerSessionRecord { pub fn client_account_id(&self) -> &str { self.client_identity.id.as_str() } + + pub fn approved_permission_labels(&self) -> Vec<String> { + self.approved_permissions + .as_slice() + .iter() + .map(ToString::to_string) + .collect() + } + + pub fn allows_sign_event_kind1(&self) -> bool { + self.approved_permissions + .as_slice() + .iter() + .any(|permission| { + permission_matches( + permission, + &RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:1", + ), + ) + }) + } + + pub fn allows_switch_relays(&self) -> bool { + self.approved_permissions + .as_slice() + .iter() + .any(|permission| { + permission_matches( + permission, + &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays), + ) + }) + } } impl RadrootsAppRemoteSignerSessionStoreState { @@ -165,6 +206,7 @@ impl RadrootsAppRemoteSignerSessionStoreState { client_account_id: &str, user_identity: RadrootsIdentityPublic, relays: Vec<String>, + approved_permissions: RadrootsNostrConnectPermissions, ) -> Option<RadrootsAppRemoteSignerSessionRecord> { let now = now_unix_secs(); self.sessions.retain(|record| { @@ -177,6 +219,7 @@ impl RadrootsAppRemoteSignerSessionStoreState { .find(|record| record.client_account_id() == client_account_id)?; record.user_identity = Some(user_identity); record.relays = relays; + record.approved_permissions = approved_permissions; record.status = RadrootsAppRemoteSignerSessionStatus::Active; record.updated_at_unix = now; Some(record.clone()) @@ -249,6 +292,33 @@ impl RadrootsAppRemoteSignerSessionStoreState { } } +fn permission_matches( + granted_permission: &RadrootsNostrConnectPermission, + required_permission: &RadrootsNostrConnectPermission, +) -> bool { + if granted_permission.method != required_permission.method { + return false; + } + + match ( + &granted_permission.method, + granted_permission.parameter.as_deref(), + required_permission.parameter.as_deref(), + ) { + (RadrootsNostrConnectMethod::SignEvent, None, _) => true, + (RadrootsNostrConnectMethod::SignEvent, Some(parameter), Some(required)) => { + parameter == required || parameter == sign_event_kind_suffix(required) + } + (_, None, _) => true, + (_, Some(parameter), Some(required)) => parameter == required, + (_, Some(_), None) => false, + } +} + +fn sign_event_kind_suffix(value: &str) -> &str { + value.strip_prefix("kind:").unwrap_or(value) +} + fn now_unix_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -285,6 +355,9 @@ fn quarantine_invalid_store(path: &Path) -> Result<(), RadrootsAppRemoteSignerEr mod tests { use super::*; use radroots_app_test_support::{FIXTURE_ALICE, FIXTURE_BOB, fixture_identity}; + use radroots_nostr_connect::prelude::{ + RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, + }; fn fixture_public( label: &radroots_app_test_support::RadrootsAppApprovedFixtureIdentity, @@ -330,6 +403,14 @@ mod tests { client_account_id.as_str(), alice_public.clone(), vec!["wss://relay.updated.example".to_owned()], + vec![ + RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "kind:1", + ), + RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays), + ] + .into(), ) .expect("active"); @@ -339,6 +420,12 @@ mod tests { active.relays, vec!["wss://relay.updated.example".to_owned()] ); + assert_eq!( + active.approved_permission_labels(), + vec!["sign_event:kind:1".to_owned(), "switch_relays".to_owned()] + ); + assert!(active.allows_sign_event_kind1()); + assert!(active.allows_switch_relays()); assert!(state.pending_session().is_none()); } @@ -353,6 +440,7 @@ mod tests { client_account_id.as_str(), alice_public.clone(), vec!["wss://relay.updated.example".to_owned()], + RadrootsNostrConnectPermissions::default(), ); let removed = state @@ -395,4 +483,19 @@ mod tests { assert!(loaded.sessions.is_empty()); assert!(!path.exists()); } + + #[test] + fn active_session_permission_helpers_respect_sign_event_and_switch_relays() { + let mut record = pending_record(); + record.user_identity = Some(fixture_public(&FIXTURE_ALICE)); + record.status = RadrootsAppRemoteSignerSessionStatus::Active; + record.approved_permissions = vec![RadrootsNostrConnectPermission::with_parameter( + RadrootsNostrConnectMethod::SignEvent, + "1", + )] + .into(); + + assert!(record.allows_sign_event_kind1()); + assert!(!record.allows_switch_relays()); + } }