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:
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());
+ }
}