commit ff89b169ee481ac142720d720f63dc200558c562
parent 053a2aeffc2546aaf8ab504ea2c3f2214f48fbae
Author: triesap <tyson@radroots.org>
Date: Thu, 2 Apr 2026 15:24:07 +0000
remote-signer: add permission-scoped note proof
Diffstat:
14 files changed, 1386 insertions(+), 133 deletions(-)
diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs
@@ -378,7 +378,7 @@ impl RadrootsAppBackend for AndroidBackend {
) -> Result<Option<radroots_app_core::RadrootsPendingRemoteSignerConnection>, String> {
#[cfg(target_os = "android")]
{
- return remote_signer::pending_connection();
+ return self.remote_signer.pending_connection();
}
#[cfg(not(target_os = "android"))]
@@ -399,6 +399,57 @@ impl RadrootsAppBackend for AndroidBackend {
}
}
+ fn remote_signer_note_action_state(&self) -> Option<SetupActionState> {
+ #[cfg(target_os = "android")]
+ {
+ return Some(
+ self.remote_signer
+ .note_action_state()
+ .unwrap_or(SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: false,
+ pending: false,
+ }),
+ );
+ }
+
+ #[cfg(not(target_os = "android"))]
+ {
+ None
+ }
+ }
+
+ fn request_remote_signer_note_action(&self, content: &str) -> Result<(), String> {
+ #[cfg(target_os = "android")]
+ {
+ return self.remote_signer.begin_sign_kind1_note_selected(content);
+ }
+
+ #[cfg(not(target_os = "android"))]
+ {
+ let _ = content;
+ Ok(())
+ }
+ }
+
+ fn poll_remote_signer_note_action_result(
+ &self,
+ ) -> Result<Option<radroots_app_core::RadrootsRemoteSignerSignedNote>, String> {
+ #[cfg(target_os = "android")]
+ {
+ return self
+ .remote_signer
+ .take_note_update()
+ .transpose()
+ .map(|result| result.flatten());
+ }
+
+ #[cfg(not(target_os = "android"))]
+ {
+ Ok(None)
+ }
+ }
+
fn home_action_states(&self) -> Vec<HomeActionState> {
#[cfg(target_os = "android")]
{
diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs
@@ -2,13 +2,15 @@ use crate::security::{ANDROID_NOSTR_SERVICE, resolve_nostr_storage_root};
use crate::vault::RadrootsAndroidKeystoreVault;
use radroots_app_core::{
IdentityGateState, RadrootsAccountCustody, RadrootsPendingRemoteSignerConnection,
- RadrootsRemoteSignerPreview, SetupActionState,
+ RadrootsRemoteSignerPreview, RadrootsRemoteSignerSignedNote, SetupActionState,
};
use radroots_app_remote_signer::{
- RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerController,
- RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession,
- RadrootsAppRemoteSignerPendingState, RadrootsAppRemoteSignerSessionRecord,
- RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session,
+ RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerActionController,
+ RadrootsAppRemoteSignerActionControllerHooks, RadrootsAppRemoteSignerActionState,
+ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerPendingState,
+ RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStoreState,
+ RadrootsAppRemoteSignerSignedEvent, radroots_app_remote_signer_clear_pending_session,
radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview,
radroots_app_remote_signer_purge_all_custody_state,
radroots_app_remote_signer_reconcile_startup,
@@ -75,9 +77,9 @@ impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks {
fn activate_pending_session(
&self,
client_account_id: &str,
- user_identity: radroots_identity::RadrootsIdentityPublic,
+ approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession,
) -> Result<Self::ReadyState, String> {
- activate_remote_session(client_account_id, user_identity)
+ activate_remote_session(client_account_id, approved)
}
fn clear_pending_session(
@@ -91,12 +93,16 @@ impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks {
#[derive(Clone)]
pub(crate) struct AndroidRemoteSigner {
controller: RadrootsAppRemoteSignerController<AndroidRemoteSignerHooks>,
+ action_controller: RadrootsAppRemoteSignerActionController<AndroidRemoteSignerHooks>,
}
impl AndroidRemoteSigner {
pub(crate) fn new() -> Self {
Self {
controller: RadrootsAppRemoteSignerController::new(AndroidRemoteSignerHooks),
+ action_controller: RadrootsAppRemoteSignerActionController::new(
+ AndroidRemoteSignerHooks,
+ ),
}
}
@@ -117,13 +123,20 @@ impl AndroidRemoteSigner {
});
}
- if pending_connection()?.is_some() {
+ if self.pending_connection()?.is_some() {
return Ok(match self.controller.pending_state() {
RadrootsAppRemoteSignerPendingState::TransportFailure { .. } => SetupActionState {
label: "Remote Signer Approval Check Retrying".to_owned(),
enabled: false,
pending: false,
},
+ RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { .. } => {
+ SetupActionState {
+ label: "Authorize Remote Signer to Continue".to_owned(),
+ enabled: false,
+ pending: false,
+ }
+ }
RadrootsAppRemoteSignerPendingState::Idle
| RadrootsAppRemoteSignerPendingState::WaitingApproval => SetupActionState {
label: "Remote Signer Waiting for Approval".to_owned(),
@@ -143,6 +156,59 @@ impl AndroidRemoteSigner {
pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> {
self.controller.begin_connect(input)
}
+
+ pub(crate) fn pending_connection(
+ &self,
+ ) -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> {
+ Ok(
+ pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection {
+ signer_npub: record.signer_identity.public_key_npub,
+ relays: record.relays,
+ auth_url: match self.controller.pending_state() {
+ RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { url } => Some(url),
+ _ => None,
+ },
+ }),
+ )
+ }
+
+ pub(crate) fn note_action_state(&self) -> Result<SetupActionState, String> {
+ if selected_remote_signer_account()?.is_none() {
+ return Ok(SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: false,
+ pending: false,
+ });
+ }
+
+ Ok(match self.action_controller.state() {
+ RadrootsAppRemoteSignerActionState::Idle => SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: true,
+ pending: false,
+ },
+ RadrootsAppRemoteSignerActionState::Signing => SetupActionState {
+ label: "Signing Remote Kind 1 Note...".to_owned(),
+ enabled: false,
+ pending: true,
+ },
+ RadrootsAppRemoteSignerActionState::AwaitingAuthorization { .. } => SetupActionState {
+ label: "Authorize Remote Signer to Continue".to_owned(),
+ enabled: false,
+ pending: false,
+ },
+ })
+ }
+
+ pub(crate) fn begin_sign_kind1_note_selected(&self, content: &str) -> Result<(), String> {
+ self.action_controller.begin_sign_kind1_note(content)
+ }
+
+ pub(crate) fn take_note_update(
+ &self,
+ ) -> Option<Result<Option<RadrootsRemoteSignerSignedNote>, String>> {
+ self.action_controller.take_update()
+ }
}
pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPreview, String> {
@@ -156,16 +222,6 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev
})
}
-pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String>
-{
- Ok(AndroidRemoteSignerHooks
- .pending_session_record()?
- .map(|record| RadrootsPendingRemoteSignerConnection {
- signer_npub: record.signer_identity.public_key_npub,
- relays: record.relays,
- }))
-}
-
pub(crate) fn identity_state_from_status(
status: RadrootsNostrSelectedAccountStatus,
) -> Result<IdentityGateState, String> {
@@ -226,12 +282,12 @@ pub(crate) fn purge_all_custody_state() -> Result<(), String> {
fn activate_remote_session(
client_account_id: &str,
- user_identity: radroots_identity::RadrootsIdentityPublic,
+ approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession,
) -> Result<IdentityGateState, String> {
let manager = crate::storage::accounts_manager()?;
manager
.upsert_public_identity(
- user_identity.clone(),
+ approved.user_identity.clone(),
Some(REMOTE_SIGNER_LABEL.to_owned()),
true,
)
@@ -240,14 +296,18 @@ fn activate_remote_session(
let activation_result = (|| -> Result<(), String> {
let mut state = load_sessions(store_path.as_path())?;
state
- .activate_session(client_account_id, user_identity.clone())
+ .activate_session(
+ client_account_id,
+ approved.user_identity.clone(),
+ approved.relays.clone(),
+ )
.ok_or_else(|| {
"pending remote signer session disappeared before activation".to_owned()
})?;
save_sessions(store_path.as_path(), &state)
})();
if let Err(error) = activation_result {
- if let Err(rollback_error) = manager.remove_account(&user_identity.id) {
+ if let Err(rollback_error) = manager.remove_account(&approved.user_identity.id) {
return Err(format!(
"{error}. remote signer account rollback needs retry: {rollback_error}"
));
@@ -255,10 +315,70 @@ fn activate_remote_session(
return Err(error);
}
Ok(IdentityGateState::Ready {
- account_id: user_identity.id.to_string(),
+ account_id: approved.user_identity.id.to_string(),
})
}
+fn selected_remote_signer_account() -> Result<Option<String>, String> {
+ let manager = crate::storage::accounts_manager()?;
+ let Some(account_id) = manager
+ .selected_account_id()
+ .map_err(|source| source.to_string())?
+ else {
+ return Ok(None);
+ };
+ if active_session_for_account_id(account_id.as_str())?.is_some() {
+ Ok(Some(account_id.to_string()))
+ } else {
+ Ok(None)
+ }
+}
+
+fn update_active_session_relays(account_id: &str, relays: Vec<String>) -> Result<(), String> {
+ let store_path = sessions_path()?;
+ let mut state = load_sessions(store_path.as_path())?;
+ let Some(mut session) = state.active_session_for_account_id(account_id).cloned() else {
+ return Err("active remote signer session disappeared before relay update".to_owned());
+ };
+ if session.relays == relays {
+ return Ok(());
+ }
+ session.relays = relays;
+ state.remove_active_session_for_account_id(account_id);
+ state.sessions.push(session);
+ save_sessions(store_path.as_path(), &state)
+}
+
+impl RadrootsAppRemoteSignerActionControllerHooks for AndroidRemoteSignerHooks {
+ type ReadyState = RadrootsRemoteSignerSignedNote;
+
+ fn selected_active_session(
+ &self,
+ ) -> Result<Option<(RadrootsAppRemoteSignerSessionRecord, String)>, String> {
+ let Some(account_id) = selected_remote_signer_account()? else {
+ return Ok(None);
+ };
+ let Some(record) = active_session_for_account_id(account_id.as_str())? else {
+ return Ok(None);
+ };
+ let secret = load_client_secret(record.client_account_id())?;
+ Ok(Some((record, secret)))
+ }
+
+ fn complete_sign_event(
+ &self,
+ signed_event: RadrootsAppRemoteSignerSignedEvent,
+ ) -> Result<Self::ReadyState, String> {
+ let Some(account_id) = selected_remote_signer_account()? else {
+ return Err("remote signer account is no longer selected".to_owned());
+ };
+ update_active_session_relays(account_id.as_str(), signed_event.relays.clone())?;
+ Ok(RadrootsRemoteSignerSignedNote {
+ event_id_hex: signed_event.event_id_hex,
+ })
+ }
+}
+
fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
@@ -23,7 +23,10 @@ pub use offline_geocoder::{
RadrootsOfflineGeocoderDiagnostic, RadrootsOfflineGeocoderPlatform,
RadrootsOfflineGeocoderState, RadrootsOfflineGeocoderUnavailableKind,
};
-pub use remote_signer::{RadrootsPendingRemoteSignerConnection, RadrootsRemoteSignerPreview};
+pub use remote_signer::{
+ RadrootsPendingRemoteSignerConnection, RadrootsRemoteSignerPreview,
+ RadrootsRemoteSignerSignedNote,
+};
pub use secret_keys::{RadrootsSecretImportMode, RadrootsSecretImportRequest};
use home_location_tools::HomeLocationTools;
@@ -138,6 +141,17 @@ pub trait RadrootsAppBackend {
fn request_cancel_pending_remote_signer_connection(&self) -> Result<(), String> {
Ok(())
}
+ fn remote_signer_note_action_state(&self) -> Option<SetupActionState> {
+ None
+ }
+ fn request_remote_signer_note_action(&self, _content: &str) -> Result<(), String> {
+ Ok(())
+ }
+ fn poll_remote_signer_note_action_result(
+ &self,
+ ) -> Result<Option<RadrootsRemoteSignerSignedNote>, String> {
+ Ok(None)
+ }
fn home_action_states(&self) -> Vec<HomeActionState> {
Vec::new()
}
@@ -290,6 +304,7 @@ pub struct RadrootsApp {
pending_secret_key_backup_entry: bool,
secret_key_backup_password_input: Zeroizing<String>,
secret_key_backup_password_confirm_input: Zeroizing<String>,
+ remote_signer_note_input: Zeroizing<String>,
revealed_secret_material: Option<RevealedSecretMaterial>,
}
@@ -437,6 +452,7 @@ impl RadrootsApp {
pending_secret_key_backup_entry: false,
secret_key_backup_password_input: Zeroizing::new(String::new()),
secret_key_backup_password_confirm_input: Zeroizing::new(String::new()),
+ remote_signer_note_input: Zeroizing::new(String::new()),
revealed_secret_material: None,
};
app.offline_geocoder_state = app.backend.offline_geocoder_state();
@@ -639,6 +655,19 @@ impl RadrootsApp {
}
}
+ fn request_remote_signer_note_action(&mut self) {
+ self.status_message = None;
+ match self
+ .backend
+ .request_remote_signer_note_action(self.remote_signer_note_input.as_str())
+ {
+ Ok(()) => {}
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
+ }
+
fn request_select_account(&mut self, account_id: &str) {
self.status_message = None;
self.clear_revealed_secret_material();
@@ -725,6 +754,19 @@ impl RadrootsApp {
self.status_message = Some(err);
}
}
+ match self.backend.poll_remote_signer_note_action_result() {
+ Ok(Some(result)) => {
+ self.remote_signer_note_input.clear();
+ self.status_message = Some(format!(
+ "Signed remote kind 1 note: {}",
+ result.event_id_hex
+ ));
+ }
+ Ok(None) => {}
+ Err(err) => {
+ self.status_message = Some(err);
+ }
+ }
match self.backend.poll_reverse_location_lookup_result() {
Ok(Some(result)) => self.home_location_tools.apply_reverse_lookup_result(result),
Ok(None) => {}
@@ -921,7 +963,12 @@ impl RadrootsApp {
}
RemoteSignerEntryState::WaitingApproval(pending) => {
ui.label(action.label.as_str());
- if action.label == "Remote Signer Approval Check Retrying" {
+ if pending.auth_url.is_some() {
+ ui.add_space(8.0);
+ ui.label(
+ "Authorize the remote signer in the browser, then keep this screen open while the app waits for the replayed response.",
+ );
+ } else if action.label == "Remote Signer Approval Check Retrying" {
ui.add_space(8.0);
ui.label(
"The app is retrying approval checks after a relay or network failure.",
@@ -940,6 +987,11 @@ impl RadrootsApp {
ui.monospace(relay);
}
}
+ if let Some(auth_url) = &pending.auth_url {
+ ui.add_space(8.0);
+ ui.label("Authorization url");
+ ui.monospace(auth_url);
+ }
ui.add_space(8.0);
if ui.button("Cancel Pending Remote Signer").clicked() {
self.request_cancel_pending_remote_signer();
@@ -970,6 +1022,29 @@ impl RadrootsApp {
ui.monospace(format!("account id: {}", summary.account_id));
ui.monospace(format!("npub: {}", summary.npub));
ui.monospace(format!("custody: {}", summary.custody.label()));
+ if summary.custody == RadrootsAccountCustody::RemoteSigner {
+ if let Some(note_action) = self.backend.remote_signer_note_action_state() {
+ if note_action.pending {
+ ui.ctx().request_repaint();
+ }
+ ui.add_space(16.0);
+ ui.label("Remote signer note");
+ ui.add_space(8.0);
+ ui.add(
+ egui::TextEdit::multiline(&mut *self.remote_signer_note_input)
+ .hint_text("Write a kind 1 note to sign through the remote signer")
+ .desired_rows(3)
+ .desired_width(ui.available_width().min(560.0)),
+ );
+ ui.add_space(8.0);
+ if ui
+ .add_enabled(note_action.enabled, egui::Button::new(note_action.label))
+ .clicked()
+ {
+ self.request_remote_signer_note_action();
+ }
+ }
+ }
} else {
ui.label("Selected account details are unavailable.");
ui.monospace(format!("account id: {selected_account_id}"));
@@ -1334,6 +1409,10 @@ mod tests {
remote_signer_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
pending_remote_signer: Rc<RefCell<Option<RadrootsPendingRemoteSignerConnection>>>,
cancel_pending_remote_signer: Rc<RefCell<VecDeque<Result<(), String>>>>,
+ remote_signer_note_action_state: Rc<RefCell<Option<SetupActionState>>>,
+ remote_signer_note_request: Rc<RefCell<VecDeque<Result<(), String>>>>,
+ remote_signer_note_poll:
+ Rc<RefCell<VecDeque<Result<Option<RadrootsRemoteSignerSignedNote>, String>>>>,
home_action_states: Rc<RefCell<Vec<HomeActionState>>>,
request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
home_setup_request: Rc<RefCell<VecDeque<Result<Option<IdentityGateState>, String>>>>,
@@ -1371,6 +1450,9 @@ mod tests {
remote_signer_request: Rc::new(RefCell::new(VecDeque::new())),
pending_remote_signer: Rc::new(RefCell::new(None)),
cancel_pending_remote_signer: Rc::new(RefCell::new(VecDeque::new())),
+ remote_signer_note_action_state: Rc::new(RefCell::new(None)),
+ remote_signer_note_request: Rc::new(RefCell::new(VecDeque::new())),
+ remote_signer_note_poll: Rc::new(RefCell::new(VecDeque::new())),
home_action_states: Rc::new(RefCell::new(Vec::new())),
request: Rc::new(RefCell::new(request.into())),
home_setup_request: Rc::new(RefCell::new(VecDeque::new())),
@@ -1644,6 +1726,26 @@ mod tests {
result
}
+ fn remote_signer_note_action_state(&self) -> Option<SetupActionState> {
+ self.remote_signer_note_action_state.borrow().clone()
+ }
+
+ fn request_remote_signer_note_action(&self, _content: &str) -> Result<(), String> {
+ self.remote_signer_note_request
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or(Ok(()))
+ }
+
+ fn poll_remote_signer_note_action_result(
+ &self,
+ ) -> Result<Option<RadrootsRemoteSignerSignedNote>, String> {
+ self.remote_signer_note_poll
+ .borrow_mut()
+ .pop_front()
+ .unwrap_or(Ok(None))
+ }
+
fn home_action_states(&self) -> Vec<HomeActionState> {
self.home_action_states.borrow().clone()
}
@@ -1743,7 +1845,7 @@ mod tests {
source_label: "discovery url".into(),
signer_npub: FIXTURE_BOB.npub.into(),
relays: vec!["ws://localhost:8080".into()],
- requested_permissions: vec!["sign_event:kind:1".into()],
+ requested_permissions: vec!["sign_event:kind:1".into(), "switch_relays".into()],
}
}
diff --git a/crates/core/src/remote_signer.rs b/crates/core/src/remote_signer.rs
@@ -11,6 +11,7 @@ impl RadrootsRemoteSignerPreview {
RadrootsPendingRemoteSignerConnection {
signer_npub: self.signer_npub.clone(),
relays: self.relays.clone(),
+ auth_url: None,
}
}
}
@@ -19,4 +20,10 @@ impl RadrootsRemoteSignerPreview {
pub struct RadrootsPendingRemoteSignerConnection {
pub signer_npub: String,
pub relays: Vec<String>,
+ pub auth_url: Option<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsRemoteSignerSignedNote {
+ pub event_id_hex: String,
}
diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs
@@ -692,7 +692,7 @@ impl RadrootsAppBackend for DesktopBackend {
) -> Result<Option<radroots_app_core::RadrootsPendingRemoteSignerConnection>, String> {
#[cfg(target_os = "macos")]
{
- return remote_signer::pending_connection();
+ return self.remote_signer.pending_connection();
}
#[cfg(not(target_os = "macos"))]
@@ -713,6 +713,57 @@ impl RadrootsAppBackend for DesktopBackend {
}
}
+ fn remote_signer_note_action_state(&self) -> Option<SetupActionState> {
+ #[cfg(target_os = "macos")]
+ {
+ return Some(
+ self.remote_signer
+ .note_action_state()
+ .unwrap_or(SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: false,
+ pending: false,
+ }),
+ );
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ None
+ }
+ }
+
+ fn request_remote_signer_note_action(&self, content: &str) -> Result<(), String> {
+ #[cfg(target_os = "macos")]
+ {
+ return self.remote_signer.begin_sign_kind1_note_selected(content);
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ let _ = content;
+ Ok(())
+ }
+ }
+
+ fn poll_remote_signer_note_action_result(
+ &self,
+ ) -> Result<Option<radroots_app_core::RadrootsRemoteSignerSignedNote>, String> {
+ #[cfg(target_os = "macos")]
+ {
+ return self
+ .remote_signer
+ .take_note_update()
+ .transpose()
+ .map(|result| result.flatten());
+ }
+
+ #[cfg(not(target_os = "macos"))]
+ {
+ Ok(None)
+ }
+ }
+
fn request_select_account(
&self,
account_id: &str,
diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs
@@ -2,13 +2,15 @@ use super::DesktopBackend;
use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault};
use radroots_app_core::{
IdentityGateState, RadrootsAccountCustody, RadrootsPendingRemoteSignerConnection,
- RadrootsRemoteSignerPreview, SetupActionState,
+ RadrootsRemoteSignerPreview, RadrootsRemoteSignerSignedNote, SetupActionState,
};
use radroots_app_remote_signer::{
- RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerController,
- RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession,
- RadrootsAppRemoteSignerPendingState, RadrootsAppRemoteSignerSessionRecord,
- RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session,
+ RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerActionController,
+ RadrootsAppRemoteSignerActionControllerHooks, RadrootsAppRemoteSignerActionState,
+ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerPendingState,
+ RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStoreState,
+ RadrootsAppRemoteSignerSignedEvent, radroots_app_remote_signer_clear_pending_session,
radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview,
radroots_app_remote_signer_purge_all_custody_state,
radroots_app_remote_signer_reconcile_startup,
@@ -74,9 +76,9 @@ impl RadrootsAppRemoteSignerControllerHooks for DesktopRemoteSignerHooks {
fn activate_pending_session(
&self,
client_account_id: &str,
- user_identity: radroots_identity::RadrootsIdentityPublic,
+ approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession,
) -> Result<Self::ReadyState, String> {
- activate_remote_session(client_account_id, user_identity)
+ activate_remote_session(client_account_id, approved)
}
fn clear_pending_session(
@@ -90,12 +92,16 @@ impl RadrootsAppRemoteSignerControllerHooks for DesktopRemoteSignerHooks {
#[derive(Clone)]
pub(crate) struct DesktopRemoteSigner {
controller: RadrootsAppRemoteSignerController<DesktopRemoteSignerHooks>,
+ action_controller: RadrootsAppRemoteSignerActionController<DesktopRemoteSignerHooks>,
}
impl DesktopRemoteSigner {
pub(crate) fn new() -> Self {
Self {
controller: RadrootsAppRemoteSignerController::new(DesktopRemoteSignerHooks),
+ action_controller: RadrootsAppRemoteSignerActionController::new(
+ DesktopRemoteSignerHooks,
+ ),
}
}
@@ -116,13 +122,20 @@ impl DesktopRemoteSigner {
});
}
- if pending_connection()?.is_some() {
+ if self.pending_connection()?.is_some() {
return Ok(match self.controller.pending_state() {
RadrootsAppRemoteSignerPendingState::TransportFailure { .. } => SetupActionState {
label: "Remote Signer Approval Check Retrying".to_owned(),
enabled: false,
pending: false,
},
+ RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { .. } => {
+ SetupActionState {
+ label: "Authorize Remote Signer to Continue".to_owned(),
+ enabled: false,
+ pending: false,
+ }
+ }
RadrootsAppRemoteSignerPendingState::Idle
| RadrootsAppRemoteSignerPendingState::WaitingApproval => SetupActionState {
label: "Remote Signer Waiting for Approval".to_owned(),
@@ -142,6 +155,59 @@ impl DesktopRemoteSigner {
pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> {
self.controller.begin_connect(input)
}
+
+ pub(crate) fn pending_connection(
+ &self,
+ ) -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> {
+ Ok(
+ pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection {
+ signer_npub: record.signer_identity.public_key_npub,
+ relays: record.relays,
+ auth_url: match self.controller.pending_state() {
+ RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { url } => Some(url),
+ _ => None,
+ },
+ }),
+ )
+ }
+
+ pub(crate) fn note_action_state(&self) -> Result<SetupActionState, String> {
+ if !selected_remote_signer_account()?.is_some() {
+ return Ok(SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: false,
+ pending: false,
+ });
+ }
+
+ Ok(match self.action_controller.state() {
+ RadrootsAppRemoteSignerActionState::Idle => SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: true,
+ pending: false,
+ },
+ RadrootsAppRemoteSignerActionState::Signing => SetupActionState {
+ label: "Signing Remote Kind 1 Note...".to_owned(),
+ enabled: false,
+ pending: true,
+ },
+ RadrootsAppRemoteSignerActionState::AwaitingAuthorization { .. } => SetupActionState {
+ label: "Authorize Remote Signer to Continue".to_owned(),
+ enabled: false,
+ pending: false,
+ },
+ })
+ }
+
+ pub(crate) fn begin_sign_kind1_note_selected(&self, content: &str) -> Result<(), String> {
+ self.action_controller.begin_sign_kind1_note(content)
+ }
+
+ pub(crate) fn take_note_update(
+ &self,
+ ) -> Option<Result<Option<RadrootsRemoteSignerSignedNote>, String>> {
+ self.action_controller.take_update()
+ }
}
pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPreview, String> {
@@ -155,16 +221,6 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev
})
}
-pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String>
-{
- Ok(DesktopRemoteSignerHooks
- .pending_session_record()?
- .map(|record| RadrootsPendingRemoteSignerConnection {
- signer_npub: record.signer_identity.public_key_npub,
- relays: record.relays,
- }))
-}
-
pub(crate) fn identity_state_from_status(
status: RadrootsNostrSelectedAccountStatus,
) -> Result<IdentityGateState, String> {
@@ -225,12 +281,12 @@ pub(crate) fn purge_all_custody_state() -> Result<(), String> {
fn activate_remote_session(
client_account_id: &str,
- user_identity: radroots_identity::RadrootsIdentityPublic,
+ approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession,
) -> Result<IdentityGateState, String> {
let manager = DesktopBackend::accounts_manager()?;
manager
.upsert_public_identity(
- user_identity.clone(),
+ approved.user_identity.clone(),
Some(REMOTE_SIGNER_LABEL.to_owned()),
true,
)
@@ -239,14 +295,18 @@ fn activate_remote_session(
let activation_result = (|| -> Result<(), String> {
let mut state = load_sessions(store_path.as_path())?;
state
- .activate_session(client_account_id, user_identity.clone())
+ .activate_session(
+ client_account_id,
+ approved.user_identity.clone(),
+ approved.relays.clone(),
+ )
.ok_or_else(|| {
"pending remote signer session disappeared before activation".to_owned()
})?;
save_sessions(store_path.as_path(), &state)
})();
if let Err(error) = activation_result {
- if let Err(rollback_error) = manager.remove_account(&user_identity.id) {
+ if let Err(rollback_error) = manager.remove_account(&approved.user_identity.id) {
return Err(format!(
"{error}. remote signer account rollback needs retry: {rollback_error}"
));
@@ -254,10 +314,70 @@ fn activate_remote_session(
return Err(error);
}
Ok(IdentityGateState::Ready {
- account_id: user_identity.id.to_string(),
+ account_id: approved.user_identity.id.to_string(),
})
}
+fn selected_remote_signer_account() -> Result<Option<String>, String> {
+ let manager = DesktopBackend::accounts_manager()?;
+ let Some(account_id) = manager
+ .selected_account_id()
+ .map_err(|source| source.to_string())?
+ else {
+ return Ok(None);
+ };
+ if active_session_for_account_id(account_id.as_str())?.is_some() {
+ Ok(Some(account_id.to_string()))
+ } else {
+ Ok(None)
+ }
+}
+
+fn update_active_session_relays(account_id: &str, relays: Vec<String>) -> Result<(), String> {
+ let store_path = sessions_path()?;
+ let mut state = load_sessions(store_path.as_path())?;
+ let Some(mut session) = state.active_session_for_account_id(account_id).cloned() else {
+ return Err("active remote signer session disappeared before relay update".to_owned());
+ };
+ if session.relays == relays {
+ return Ok(());
+ }
+ session.relays = relays;
+ state.remove_active_session_for_account_id(account_id);
+ state.sessions.push(session);
+ save_sessions(store_path.as_path(), &state)
+}
+
+impl RadrootsAppRemoteSignerActionControllerHooks for DesktopRemoteSignerHooks {
+ type ReadyState = RadrootsRemoteSignerSignedNote;
+
+ fn selected_active_session(
+ &self,
+ ) -> Result<Option<(RadrootsAppRemoteSignerSessionRecord, String)>, String> {
+ let Some(account_id) = selected_remote_signer_account()? else {
+ return Ok(None);
+ };
+ let Some(record) = active_session_for_account_id(account_id.as_str())? else {
+ return Ok(None);
+ };
+ let secret = load_client_secret(record.client_account_id())?;
+ Ok(Some((record, secret)))
+ }
+
+ fn complete_sign_event(
+ &self,
+ signed_event: RadrootsAppRemoteSignerSignedEvent,
+ ) -> Result<Self::ReadyState, String> {
+ let Some(account_id) = selected_remote_signer_account()? else {
+ return Err("remote signer account is no longer selected".to_owned());
+ };
+ update_active_session_relays(account_id.as_str(), signed_event.relays.clone())?;
+ Ok(RadrootsRemoteSignerSignedNote {
+ event_id_hex: signed_event.event_id_hex,
+ })
+ }
+}
+
fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
@@ -361,7 +481,7 @@ mod tests {
assert_eq!(preview.relays, vec!["ws://localhost:8080".to_owned()]);
assert_eq!(
preview.requested_permissions,
- vec!["sign_event:kind:1".to_owned()]
+ vec!["sign_event:kind:1".to_owned(), "switch_relays".to_owned(),]
);
}
}
diff --git a/crates/ios/src/lib.rs b/crates/ios/src/lib.rs
@@ -544,13 +544,38 @@ impl RadrootsAppBackend for IosBackend {
fn pending_remote_signer_connection(
&self,
) -> Result<Option<radroots_app_core::RadrootsPendingRemoteSignerConnection>, String> {
- remote_signer::pending_connection()
+ self.remote_signer.pending_connection()
}
fn request_cancel_pending_remote_signer_connection(&self) -> Result<(), String> {
remote_signer::cancel_pending_connection()
}
+ fn remote_signer_note_action_state(&self) -> Option<SetupActionState> {
+ Some(
+ self.remote_signer
+ .note_action_state()
+ .unwrap_or(SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: false,
+ pending: false,
+ }),
+ )
+ }
+
+ fn request_remote_signer_note_action(&self, content: &str) -> Result<(), String> {
+ self.remote_signer.begin_sign_kind1_note_selected(content)
+ }
+
+ fn poll_remote_signer_note_action_result(
+ &self,
+ ) -> Result<Option<radroots_app_core::RadrootsRemoteSignerSignedNote>, String> {
+ self.remote_signer
+ .take_note_update()
+ .transpose()
+ .map(|result| result.flatten())
+ }
+
fn import_paste_action_state(&self) -> Option<PasteActionState> {
Some(PasteActionState {
label: "Paste Secret Key".to_owned(),
diff --git a/crates/ios/src/remote_signer.rs b/crates/ios/src/remote_signer.rs
@@ -2,13 +2,15 @@ use crate::storage;
use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault};
use radroots_app_core::{
IdentityGateState, RadrootsAccountCustody, RadrootsPendingRemoteSignerConnection,
- RadrootsRemoteSignerPreview, SetupActionState,
+ RadrootsRemoteSignerPreview, RadrootsRemoteSignerSignedNote, SetupActionState,
};
use radroots_app_remote_signer::{
- RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerController,
- RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession,
- RadrootsAppRemoteSignerPendingState, RadrootsAppRemoteSignerSessionRecord,
- RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session,
+ RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE, RadrootsAppRemoteSignerActionController,
+ RadrootsAppRemoteSignerActionControllerHooks, RadrootsAppRemoteSignerActionState,
+ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerPendingState,
+ RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStoreState,
+ RadrootsAppRemoteSignerSignedEvent, radroots_app_remote_signer_clear_pending_session,
radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview,
radroots_app_remote_signer_purge_all_custody_state,
radroots_app_remote_signer_reconcile_startup,
@@ -75,9 +77,9 @@ impl RadrootsAppRemoteSignerControllerHooks for IosRemoteSignerHooks {
fn activate_pending_session(
&self,
client_account_id: &str,
- user_identity: radroots_identity::RadrootsIdentityPublic,
+ approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession,
) -> Result<Self::ReadyState, String> {
- activate_remote_session(client_account_id, user_identity)
+ activate_remote_session(client_account_id, approved)
}
fn clear_pending_session(
@@ -91,12 +93,14 @@ impl RadrootsAppRemoteSignerControllerHooks for IosRemoteSignerHooks {
#[derive(Clone)]
pub(crate) struct IosRemoteSigner {
controller: RadrootsAppRemoteSignerController<IosRemoteSignerHooks>,
+ action_controller: RadrootsAppRemoteSignerActionController<IosRemoteSignerHooks>,
}
impl IosRemoteSigner {
pub(crate) fn new() -> Self {
Self {
controller: RadrootsAppRemoteSignerController::new(IosRemoteSignerHooks),
+ action_controller: RadrootsAppRemoteSignerActionController::new(IosRemoteSignerHooks),
}
}
@@ -117,13 +121,20 @@ impl IosRemoteSigner {
});
}
- if pending_connection()?.is_some() {
+ if self.pending_connection()?.is_some() {
return Ok(match self.controller.pending_state() {
RadrootsAppRemoteSignerPendingState::TransportFailure { .. } => SetupActionState {
label: "Remote Signer Approval Check Retrying".to_owned(),
enabled: false,
pending: false,
},
+ RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { .. } => {
+ SetupActionState {
+ label: "Authorize Remote Signer to Continue".to_owned(),
+ enabled: false,
+ pending: false,
+ }
+ }
RadrootsAppRemoteSignerPendingState::Idle
| RadrootsAppRemoteSignerPendingState::WaitingApproval => SetupActionState {
label: "Remote Signer Waiting for Approval".to_owned(),
@@ -143,6 +154,59 @@ impl IosRemoteSigner {
pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> {
self.controller.begin_connect(input)
}
+
+ pub(crate) fn pending_connection(
+ &self,
+ ) -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> {
+ Ok(
+ pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection {
+ signer_npub: record.signer_identity.public_key_npub,
+ relays: record.relays,
+ auth_url: match self.controller.pending_state() {
+ RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { url } => Some(url),
+ _ => None,
+ },
+ }),
+ )
+ }
+
+ pub(crate) fn note_action_state(&self) -> Result<SetupActionState, String> {
+ if selected_remote_signer_account()?.is_none() {
+ return Ok(SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: false,
+ pending: false,
+ });
+ }
+
+ Ok(match self.action_controller.state() {
+ RadrootsAppRemoteSignerActionState::Idle => SetupActionState {
+ label: "Sign Remote Kind 1 Note".to_owned(),
+ enabled: true,
+ pending: false,
+ },
+ RadrootsAppRemoteSignerActionState::Signing => SetupActionState {
+ label: "Signing Remote Kind 1 Note...".to_owned(),
+ enabled: false,
+ pending: true,
+ },
+ RadrootsAppRemoteSignerActionState::AwaitingAuthorization { .. } => SetupActionState {
+ label: "Authorize Remote Signer to Continue".to_owned(),
+ enabled: false,
+ pending: false,
+ },
+ })
+ }
+
+ pub(crate) fn begin_sign_kind1_note_selected(&self, content: &str) -> Result<(), String> {
+ self.action_controller.begin_sign_kind1_note(content)
+ }
+
+ pub(crate) fn take_note_update(
+ &self,
+ ) -> Option<Result<Option<RadrootsRemoteSignerSignedNote>, String>> {
+ self.action_controller.take_update()
+ }
}
pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPreview, String> {
@@ -156,16 +220,6 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev
})
}
-pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String>
-{
- Ok(IosRemoteSignerHooks
- .pending_session_record()?
- .map(|record| RadrootsPendingRemoteSignerConnection {
- signer_npub: record.signer_identity.public_key_npub,
- relays: record.relays,
- }))
-}
-
pub(crate) fn identity_state_from_status(
status: RadrootsNostrSelectedAccountStatus,
) -> Result<IdentityGateState, String> {
@@ -226,12 +280,12 @@ pub(crate) fn purge_all_custody_state() -> Result<(), String> {
fn activate_remote_session(
client_account_id: &str,
- user_identity: radroots_identity::RadrootsIdentityPublic,
+ approved: radroots_app_remote_signer::RadrootsAppRemoteSignerApprovedSession,
) -> Result<IdentityGateState, String> {
let manager = storage::accounts_manager()?;
manager
.upsert_public_identity(
- user_identity.clone(),
+ approved.user_identity.clone(),
Some(REMOTE_SIGNER_LABEL.to_owned()),
true,
)
@@ -240,14 +294,18 @@ fn activate_remote_session(
let activation_result = (|| -> Result<(), String> {
let mut state = load_sessions(store_path.as_path())?;
state
- .activate_session(client_account_id, user_identity.clone())
+ .activate_session(
+ client_account_id,
+ approved.user_identity.clone(),
+ approved.relays.clone(),
+ )
.ok_or_else(|| {
"pending remote signer session disappeared before activation".to_owned()
})?;
save_sessions(store_path.as_path(), &state)
})();
if let Err(error) = activation_result {
- if let Err(rollback_error) = manager.remove_account(&user_identity.id) {
+ if let Err(rollback_error) = manager.remove_account(&approved.user_identity.id) {
return Err(format!(
"{error}. remote signer account rollback needs retry: {rollback_error}"
));
@@ -255,10 +313,70 @@ fn activate_remote_session(
return Err(error);
}
Ok(IdentityGateState::Ready {
- account_id: user_identity.id.to_string(),
+ account_id: approved.user_identity.id.to_string(),
})
}
+fn selected_remote_signer_account() -> Result<Option<String>, String> {
+ let manager = storage::accounts_manager()?;
+ let Some(account_id) = manager
+ .selected_account_id()
+ .map_err(|source| source.to_string())?
+ else {
+ return Ok(None);
+ };
+ if active_session_for_account_id(account_id.as_str())?.is_some() {
+ Ok(Some(account_id.to_string()))
+ } else {
+ Ok(None)
+ }
+}
+
+fn update_active_session_relays(account_id: &str, relays: Vec<String>) -> Result<(), String> {
+ let store_path = sessions_path()?;
+ let mut state = load_sessions(store_path.as_path())?;
+ let Some(mut session) = state.active_session_for_account_id(account_id).cloned() else {
+ return Err("active remote signer session disappeared before relay update".to_owned());
+ };
+ if session.relays == relays {
+ return Ok(());
+ }
+ session.relays = relays;
+ state.remove_active_session_for_account_id(account_id);
+ state.sessions.push(session);
+ save_sessions(store_path.as_path(), &state)
+}
+
+impl RadrootsAppRemoteSignerActionControllerHooks for IosRemoteSignerHooks {
+ type ReadyState = RadrootsRemoteSignerSignedNote;
+
+ fn selected_active_session(
+ &self,
+ ) -> Result<Option<(RadrootsAppRemoteSignerSessionRecord, String)>, String> {
+ let Some(account_id) = selected_remote_signer_account()? else {
+ return Ok(None);
+ };
+ let Some(record) = active_session_for_account_id(account_id.as_str())? else {
+ return Ok(None);
+ };
+ let secret = load_client_secret(record.client_account_id())?;
+ Ok(Some((record, secret)))
+ }
+
+ fn complete_sign_event(
+ &self,
+ signed_event: RadrootsAppRemoteSignerSignedEvent,
+ ) -> Result<Self::ReadyState, String> {
+ let Some(account_id) = selected_remote_signer_account()? else {
+ return Err("remote signer account is no longer selected".to_owned());
+ };
+ update_active_session_relays(account_id.as_str(), signed_event.relays.clone())?;
+ Ok(RadrootsRemoteSignerSignedNote {
+ event_id_hex: signed_event.event_id_hex,
+ })
+ }
+}
+
fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> {
let store_path = sessions_path()?;
let state = load_sessions(store_path.as_path())?;
diff --git a/crates/remote-signer/src/action.rs b/crates/remote-signer/src/action.rs
@@ -0,0 +1,327 @@
+use crate::protocol::{
+ RadrootsAppRemoteSignerProgressUpdate, RadrootsAppRemoteSignerSignedEvent,
+ radroots_app_remote_signer_sign_kind1_note_with_progress,
+};
+use crate::session::RadrootsAppRemoteSignerSessionRecord;
+use std::marker::PhantomData;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+
+type RadrootsAppRemoteSignerSignNoteFn = Arc<
+ dyn Fn(
+ &RadrootsAppRemoteSignerSessionRecord,
+ &str,
+ &str,
+ Arc<dyn Fn(RadrootsAppRemoteSignerProgressUpdate) + Send + Sync>,
+ ) -> Result<RadrootsAppRemoteSignerSignedEvent, String>
+ + Send
+ + Sync,
+>;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsAppRemoteSignerActionState {
+ Idle,
+ Signing,
+ AwaitingAuthorization { url: String },
+}
+
+pub trait RadrootsAppRemoteSignerActionControllerHooks: Clone + Send + Sync + 'static {
+ type ReadyState: Send + Sync + 'static;
+
+ fn selected_active_session(
+ &self,
+ ) -> Result<Option<(RadrootsAppRemoteSignerSessionRecord, String)>, String>;
+
+ fn complete_sign_event(
+ &self,
+ signed_event: RadrootsAppRemoteSignerSignedEvent,
+ ) -> Result<Self::ReadyState, String>;
+}
+
+pub struct RadrootsAppRemoteSignerActionController<H>
+where
+ H: RadrootsAppRemoteSignerActionControllerHooks,
+{
+ hooks: H,
+ sign_kind1_note: RadrootsAppRemoteSignerSignNoteFn,
+ update: Arc<Mutex<Option<Result<Option<H::ReadyState>, String>>>>,
+ changed: Arc<AtomicBool>,
+ signing: Arc<AtomicBool>,
+ state: Arc<Mutex<RadrootsAppRemoteSignerActionState>>,
+ _ready_state: PhantomData<H::ReadyState>,
+}
+
+impl<H> Clone for RadrootsAppRemoteSignerActionController<H>
+where
+ H: RadrootsAppRemoteSignerActionControllerHooks,
+{
+ fn clone(&self) -> Self {
+ Self {
+ hooks: self.hooks.clone(),
+ sign_kind1_note: Arc::clone(&self.sign_kind1_note),
+ update: Arc::clone(&self.update),
+ changed: Arc::clone(&self.changed),
+ signing: Arc::clone(&self.signing),
+ state: Arc::clone(&self.state),
+ _ready_state: PhantomData,
+ }
+ }
+}
+
+impl<H> RadrootsAppRemoteSignerActionController<H>
+where
+ H: RadrootsAppRemoteSignerActionControllerHooks,
+{
+ pub fn new(hooks: H) -> Self {
+ Self {
+ hooks,
+ sign_kind1_note: Arc::new(default_sign_kind1_note),
+ update: Arc::new(Mutex::new(None)),
+ changed: Arc::new(AtomicBool::new(false)),
+ signing: Arc::new(AtomicBool::new(false)),
+ state: Arc::new(Mutex::new(RadrootsAppRemoteSignerActionState::Idle)),
+ _ready_state: PhantomData,
+ }
+ }
+
+ #[cfg(test)]
+ fn new_with_ops(hooks: H, sign_kind1_note: RadrootsAppRemoteSignerSignNoteFn) -> Self {
+ Self {
+ hooks,
+ sign_kind1_note,
+ update: Arc::new(Mutex::new(None)),
+ changed: Arc::new(AtomicBool::new(false)),
+ signing: Arc::new(AtomicBool::new(false)),
+ state: Arc::new(Mutex::new(RadrootsAppRemoteSignerActionState::Idle)),
+ _ready_state: PhantomData,
+ }
+ }
+
+ pub fn take_update(&self) -> Option<Result<Option<H::ReadyState>, String>> {
+ if !self.changed.swap(false, Ordering::AcqRel) {
+ return None;
+ }
+ self.update.lock().ok().and_then(|mut slot| slot.take())
+ }
+
+ pub fn is_signing(&self) -> bool {
+ self.signing.load(Ordering::Acquire)
+ }
+
+ pub fn state(&self) -> RadrootsAppRemoteSignerActionState {
+ self.state
+ .lock()
+ .map(|state| state.clone())
+ .unwrap_or(RadrootsAppRemoteSignerActionState::Idle)
+ }
+
+ pub fn begin_sign_kind1_note(&self, content: &str) -> Result<(), String> {
+ if self.signing.swap(true, Ordering::AcqRel) {
+ return Err("remote signer note signing is already running".to_owned());
+ }
+
+ let Some((record, client_secret_key_hex)) = self.hooks.selected_active_session()? else {
+ self.signing.store(false, Ordering::Release);
+ return Err("select a remote signer account before signing a note".to_owned());
+ };
+ let note_content = content.trim().to_owned();
+ if note_content.is_empty() {
+ self.signing.store(false, Ordering::Release);
+ return Err("enter a note before requesting a remote signature".to_owned());
+ }
+
+ self.set_state(RadrootsAppRemoteSignerActionState::Signing);
+ if let Ok(mut slot) = self.update.lock() {
+ *slot = None;
+ }
+
+ let tracker = self.clone();
+ std::thread::spawn(move || {
+ let progress_tracker = tracker.clone();
+ let progress: Arc<dyn Fn(RadrootsAppRemoteSignerProgressUpdate) + Send + Sync> =
+ Arc::new(move |update| progress_tracker.apply_progress(update));
+ let outcome = (tracker.sign_kind1_note)(
+ &record,
+ client_secret_key_hex.as_str(),
+ note_content.as_str(),
+ progress,
+ )
+ .and_then(|signed_event| tracker.hooks.complete_sign_event(signed_event));
+
+ tracker.set_state(RadrootsAppRemoteSignerActionState::Idle);
+ tracker.signing.store(false, Ordering::Release);
+ match outcome {
+ Ok(result) => tracker.push_update(Ok(Some(result))),
+ Err(error) => tracker.push_update(Err(error)),
+ }
+ });
+
+ Ok(())
+ }
+
+ fn apply_progress(&self, update: RadrootsAppRemoteSignerProgressUpdate) {
+ match update {
+ RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { url } => {
+ let next =
+ RadrootsAppRemoteSignerActionState::AwaitingAuthorization { url: url.clone() };
+ if self.set_state(next) {
+ self.push_update(Err(format!(
+ "authorize the remote signer to continue: {url}"
+ )));
+ }
+ }
+ }
+ }
+
+ fn push_update(&self, result: Result<Option<H::ReadyState>, String>) {
+ if let Ok(mut slot) = self.update.lock() {
+ *slot = Some(result);
+ self.changed.store(true, Ordering::Release);
+ }
+ }
+
+ fn set_state(&self, next: RadrootsAppRemoteSignerActionState) -> bool {
+ if let Ok(mut state) = self.state.lock() {
+ if *state == next {
+ return false;
+ }
+ *state = next;
+ return true;
+ }
+ false
+ }
+}
+
+fn default_sign_kind1_note(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ content: &str,
+ progress: Arc<dyn Fn(RadrootsAppRemoteSignerProgressUpdate) + Send + Sync>,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, String> {
+ radroots_app_remote_signer_sign_kind1_note_with_progress(
+ record,
+ client_secret_key_hex,
+ content,
+ move |update| progress(update),
+ )
+ .map_err(|error| error.to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::session::RadrootsAppRemoteSignerSessionRecord;
+ use radroots_app_test_support::{FIXTURE_ALICE, FIXTURE_BOB, fixture_identity};
+ use std::sync::mpsc;
+ use std::sync::{Condvar, Mutex};
+ use std::time::Duration;
+
+ #[derive(Clone)]
+ struct TestHooks {
+ session: Option<(RadrootsAppRemoteSignerSessionRecord, String)>,
+ }
+
+ impl RadrootsAppRemoteSignerActionControllerHooks for TestHooks {
+ type ReadyState = String;
+
+ fn selected_active_session(
+ &self,
+ ) -> Result<Option<(RadrootsAppRemoteSignerSessionRecord, String)>, String> {
+ Ok(self.session.clone())
+ }
+
+ fn complete_sign_event(
+ &self,
+ signed_event: RadrootsAppRemoteSignerSignedEvent,
+ ) -> Result<Self::ReadyState, String> {
+ Ok(signed_event.event_id_hex)
+ }
+ }
+
+ fn fixture_session() -> RadrootsAppRemoteSignerSessionRecord {
+ let client = fixture_identity(&FIXTURE_ALICE)
+ .expect("client")
+ .to_public();
+ let signer = fixture_identity(&FIXTURE_BOB).expect("signer").to_public();
+ let mut record = RadrootsAppRemoteSignerSessionRecord::pending(
+ client,
+ signer.clone(),
+ vec!["ws://localhost:8080".to_owned()],
+ );
+ record.user_identity = Some(signer);
+ record.status = crate::session::RadrootsAppRemoteSignerSessionStatus::Active;
+ record
+ }
+
+ fn wait_for_update(
+ controller: &RadrootsAppRemoteSignerActionController<TestHooks>,
+ ) -> Result<Option<String>, String> {
+ let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ loop {
+ if let Some(update) = controller.take_update() {
+ return update;
+ }
+ if std::time::Instant::now() >= deadline {
+ panic!("timed out waiting for action update");
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ }
+ }
+
+ #[test]
+ fn sign_controller_reports_auth_challenge_then_success() {
+ let hooks = TestHooks {
+ session: Some((fixture_session(), "client-secret".to_owned())),
+ };
+ let (challenge_seen_tx, challenge_seen_rx) = mpsc::channel();
+ let release_gate = Arc::new((Mutex::new(false), Condvar::new()));
+ let controller = RadrootsAppRemoteSignerActionController::new_with_ops(
+ hooks,
+ Arc::new({
+ let release_gate = Arc::clone(&release_gate);
+ move |_, _, _, progress| {
+ progress(RadrootsAppRemoteSignerProgressUpdate::AuthChallenge {
+ url: "http://localhost/auth".to_owned(),
+ });
+ challenge_seen_tx.send(()).expect("challenge seen");
+ let (released, condvar) = &*release_gate;
+ let mut released = released.lock().expect("release gate lock");
+ while !*released {
+ released = condvar.wait(released).expect("release gate wait");
+ }
+ Ok(RadrootsAppRemoteSignerSignedEvent {
+ event_id_hex: "deadbeef".to_owned(),
+ event_json: "{\"id\":\"deadbeef\"}".to_owned(),
+ relays: vec!["ws://localhost:8080".to_owned()],
+ })
+ }
+ }),
+ );
+
+ controller
+ .begin_sign_kind1_note("hello from remote signer")
+ .expect("begin signing");
+
+ challenge_seen_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("challenge notification");
+ let first = wait_for_update(&controller).expect_err("auth challenge status");
+ assert_eq!(
+ first,
+ "authorize the remote signer to continue: http://localhost/auth"
+ );
+ assert_eq!(
+ controller.state(),
+ RadrootsAppRemoteSignerActionState::AwaitingAuthorization {
+ url: "http://localhost/auth".to_owned()
+ }
+ );
+
+ let (released, condvar) = &*release_gate;
+ *released.lock().expect("release gate lock") = true;
+ condvar.notify_one();
+ let second = wait_for_update(&controller).expect("signed");
+ assert_eq!(second, Some("deadbeef".to_owned()));
+ assert_eq!(controller.state(), RadrootsAppRemoteSignerActionState::Idle);
+ }
+}
diff --git a/crates/remote-signer/src/controller.rs b/crates/remote-signer/src/controller.rs
@@ -1,6 +1,8 @@
use crate::protocol::{
- RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerPendingSession,
- radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session,
+ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerProgressUpdate,
+ radroots_app_remote_signer_connect_pending,
+ radroots_app_remote_signer_poll_pending_session_with_progress,
};
use crate::session::RadrootsAppRemoteSignerSessionRecord;
use std::marker::PhantomData;
@@ -14,6 +16,7 @@ type RadrootsAppRemoteSignerPollPendingFn = Arc<
dyn Fn(
&RadrootsAppRemoteSignerSessionRecord,
&str,
+ Arc<dyn Fn(RadrootsAppRemoteSignerProgressUpdate) + Send + Sync>,
) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, String>
+ Send
+ Sync,
@@ -24,11 +27,12 @@ type RadrootsAppRemoteSignerSleepFn = Arc<dyn Fn(Duration) + Send + Sync>;
pub enum RadrootsAppRemoteSignerPendingState {
Idle,
WaitingApproval,
+ AwaitingAuthorization { url: String },
TransportFailure { message: String },
}
pub trait RadrootsAppRemoteSignerControllerHooks: Clone + Send + Sync + 'static {
- type ReadyState: Send + 'static;
+ type ReadyState: Send + Sync + 'static;
fn reconcile_startup_state(&self) -> Result<(), String> {
Ok(())
@@ -48,7 +52,7 @@ pub trait RadrootsAppRemoteSignerControllerHooks: Clone + Send + Sync + 'static
fn activate_pending_session(
&self,
client_account_id: &str,
- user_identity: radroots_identity::RadrootsIdentityPublic,
+ approved: RadrootsAppRemoteSignerApprovedSession,
) -> Result<Self::ReadyState, String>;
fn clear_pending_session(&self)
@@ -242,7 +246,15 @@ where
}
};
- match (tracker.poll_pending)(&pending_record, client_secret_key_hex.as_str()) {
+ let progress_tracker = tracker.clone();
+ let progress: Arc<dyn Fn(RadrootsAppRemoteSignerProgressUpdate) + Send + Sync> =
+ Arc::new(move |update| progress_tracker.apply_progress(update));
+
+ match (tracker.poll_pending)(
+ &pending_record,
+ client_secret_key_hex.as_str(),
+ progress,
+ ) {
Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) => {
tracker.set_pending_state(
RadrootsAppRemoteSignerPendingState::WaitingApproval,
@@ -262,12 +274,12 @@ where
}
(tracker.sleep)(Duration::from_secs(1));
}
- Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => {
+ Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(approved)) => {
tracker.set_pending_state(RadrootsAppRemoteSignerPendingState::Idle);
- let ready_state = match tracker.hooks.activate_pending_session(
- pending_record.client_account_id(),
- user_identity,
- ) {
+ let ready_state = match tracker
+ .hooks
+ .activate_pending_session(pending_record.client_account_id(), approved)
+ {
Ok(state) => state,
Err(error) => {
tracker
@@ -317,6 +329,20 @@ where
}
}
+ fn apply_progress(&self, update: RadrootsAppRemoteSignerProgressUpdate) {
+ match update {
+ RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { url } => {
+ let next =
+ RadrootsAppRemoteSignerPendingState::AwaitingAuthorization { url: url.clone() };
+ if self.set_pending_state(next) {
+ self.push_update(Err(format!(
+ "authorize the remote signer to continue: {url}"
+ )));
+ }
+ }
+ }
+ }
+
fn set_pending_state(&self, next: RadrootsAppRemoteSignerPendingState) -> bool {
if let Ok(mut state) = self.pending_state.lock() {
if *state == next {
@@ -343,9 +369,14 @@ fn default_connect_pending(input: &str) -> Result<RadrootsAppRemoteSignerPending
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(record, client_secret_key_hex)
- .map_err(|error| error.to_string())
+ radroots_app_remote_signer_poll_pending_session_with_progress(
+ record,
+ client_secret_key_hex,
+ move |update| progress(update),
+ )
+ .map_err(|error| error.to_string())
}
#[cfg(test)]
@@ -475,17 +506,18 @@ mod tests {
fn activate_pending_session(
&self,
client_account_id: &str,
- user_identity: radroots_identity::RadrootsIdentityPublic,
+ approved: RadrootsAppRemoteSignerApprovedSession,
) -> Result<Self::ReadyState, String> {
let mut state = self
.state
.lock()
.map_err(|_| "hooks lock poisoned".to_owned())?;
state.pending = None;
- state
- .active
- .insert(client_account_id.to_owned(), user_identity.id.to_string());
- Ok(user_identity.id.to_string())
+ state.active.insert(
+ client_account_id.to_owned(),
+ approved.user_identity.id.to_string(),
+ );
+ Ok(approved.user_identity.id.to_string())
}
fn clear_pending_session(
@@ -588,7 +620,7 @@ mod tests {
let controller = RadrootsAppRemoteSignerController::new_with_ops(
hooks.clone(),
Arc::new(pending_session_for_input),
- Arc::new(move |record, _| {
+ Arc::new(move |record, _, _progress| {
poll_tx
.send(record.client_account_id().to_owned())
.expect("send poll id");
@@ -629,7 +661,7 @@ mod tests {
let controller = RadrootsAppRemoteSignerController::new_with_ops(
hooks.clone(),
Arc::new(pending_session_for_input),
- Arc::new(move |record, _| {
+ Arc::new(move |record, _, _progress| {
poll_tx
.send(record.client_account_id().to_owned())
.expect("send poll id");
@@ -672,7 +704,7 @@ mod tests {
let controller = RadrootsAppRemoteSignerController::new_with_ops(
hooks.clone(),
Arc::new(pending_session_for_input),
- Arc::new(move |record, _| {
+ Arc::new(move |record, _, _progress| {
poll_tx
.send(record.client_account_id().to_owned())
.expect("send poll id");
@@ -734,7 +766,7 @@ mod tests {
let controller = RadrootsAppRemoteSignerController::new_with_ops(
hooks.clone(),
Arc::new(pending_session_for_input),
- Arc::new(move |_, _| {
+ Arc::new(move |_, _, _progress| {
let next = outcomes
.lock()
.expect("outcomes lock")
diff --git a/crates/remote-signer/src/input.rs b/crates/remote-signer/src/input.rs
@@ -40,10 +40,13 @@ impl RadrootsAppRemoteSignerTarget {
}
pub fn radroots_app_remote_signer_requested_permissions() -> RadrootsNostrConnectPermissions {
- vec![RadrootsNostrConnectPermission::with_parameter(
- RadrootsNostrConnectMethod::SignEvent,
- "kind:1",
- )]
+ vec![
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:1",
+ ),
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays),
+ ]
.into()
}
@@ -135,7 +138,7 @@ mod tests {
assert_eq!(preview.connect_secret, None);
assert_eq!(
preview.requested_permission_labels(),
- vec!["sign_event:kind:1".to_owned()]
+ vec!["sign_event:kind:1".to_owned(), "switch_relays".to_owned()]
);
}
@@ -149,7 +152,7 @@ mod tests {
assert_eq!(preview.relays, vec![RELAY_PRIMARY_WSS.to_owned()]);
assert_eq!(
preview.requested_permission_labels(),
- vec!["sign_event:kind:1".to_owned()]
+ vec!["sign_event:kind:1".to_owned(), "switch_relays".to_owned()]
);
}
diff --git a/crates/remote-signer/src/lib.rs b/crates/remote-signer/src/lib.rs
@@ -1,5 +1,6 @@
#![forbid(unsafe_code)]
+mod action;
mod controller;
mod custody;
mod error;
@@ -9,6 +10,10 @@ mod session;
pub const RADROOTS_APP_REMOTE_SIGNER_SECRET_NAMESPACE: &str = "remote-signer";
+pub use action::{
+ RadrootsAppRemoteSignerActionController, RadrootsAppRemoteSignerActionControllerHooks,
+ RadrootsAppRemoteSignerActionState,
+};
pub use controller::{
RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks,
RadrootsAppRemoteSignerPendingState,
@@ -25,8 +30,15 @@ pub use input::{
radroots_app_remote_signer_preview, radroots_app_remote_signer_requested_permissions,
};
pub use protocol::{
- RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerPendingSession,
- radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session,
+ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerProgressUpdate,
+ RadrootsAppRemoteSignerSignedEvent, radroots_app_remote_signer_connect_pending,
+ radroots_app_remote_signer_poll_pending_session,
+ radroots_app_remote_signer_poll_pending_session_with_progress,
+ radroots_app_remote_signer_sign_kind1_note,
+ radroots_app_remote_signer_sign_kind1_note_with_progress,
+ radroots_app_remote_signer_sign_unsigned_event,
+ radroots_app_remote_signer_sign_unsigned_event_with_progress,
};
pub use session::{
RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION, RadrootsAppRemoteSignerSessionRecord,
diff --git a/crates/remote-signer/src/protocol.rs b/crates/remote-signer/src/protocol.rs
@@ -1,8 +1,10 @@
use crate::error::RadrootsAppRemoteSignerError;
use crate::input::{RadrootsAppRemoteSignerTarget, radroots_app_remote_signer_preview};
use crate::session::RadrootsAppRemoteSignerSessionRecord;
+use nostr::JsonUtil;
use nostr::nips::nip44;
use nostr::nips::nip44::Version;
+use nostr::{EventBuilder, UnsignedEvent};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
use radroots_nostr::prelude::{
RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter,
@@ -22,7 +24,9 @@ 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(10);
+const GET_PUBLIC_KEY_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);
#[derive(Debug, Clone)]
@@ -32,9 +36,27 @@ pub struct RadrootsAppRemoteSignerPendingSession {
}
#[derive(Debug, Clone)]
+pub struct RadrootsAppRemoteSignerApprovedSession {
+ pub user_identity: RadrootsIdentityPublic,
+ pub relays: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsAppRemoteSignerSignedEvent {
+ pub event_id_hex: String,
+ pub event_json: String,
+ pub relays: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsAppRemoteSignerProgressUpdate {
+ AuthChallenge { url: String },
+}
+
+#[derive(Debug, Clone)]
pub enum RadrootsAppRemoteSignerPendingPollOutcome {
PendingApproval,
- Approved(RadrootsIdentityPublic),
+ Approved(RadrootsAppRemoteSignerApprovedSession),
TransportFailure { message: String },
Rejected { message: String },
FatalError { message: String },
@@ -55,11 +77,98 @@ pub fn radroots_app_remote_signer_poll_pending_session(
record: &RadrootsAppRemoteSignerSessionRecord,
client_secret_key_hex: &str,
) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> {
+ radroots_app_remote_signer_poll_pending_session_with_progress(
+ record,
+ client_secret_key_hex,
+ |_| {},
+ )
+}
+
+pub fn radroots_app_remote_signer_poll_pending_session_with_progress<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ mut progress: F,
+) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError>
+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,
+ ))
+}
+
+pub fn radroots_app_remote_signer_sign_kind1_note(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ content: &str,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError> {
+ radroots_app_remote_signer_sign_kind1_note_with_progress(
+ record,
+ client_secret_key_hex,
+ content,
+ |_| {},
+ )
+}
+
+pub fn radroots_app_remote_signer_sign_kind1_note_with_progress<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ content: &str,
+ mut progress: F,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
+where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+{
+ let runtime = Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
+ runtime.block_on(sign_kind1_note(
+ record,
+ client_secret_key_hex,
+ content,
+ &mut progress,
+ ))
+}
+
+pub fn radroots_app_remote_signer_sign_unsigned_event(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ unsigned_event: UnsignedEvent,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError> {
+ radroots_app_remote_signer_sign_unsigned_event_with_progress(
+ record,
+ client_secret_key_hex,
+ unsigned_event,
+ |_| {},
+ )
+}
+
+pub fn radroots_app_remote_signer_sign_unsigned_event_with_progress<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ unsigned_event: UnsignedEvent,
+ mut progress: F,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
+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))
+ runtime.block_on(sign_unsigned_event(
+ record,
+ client_secret_key_hex,
+ unsigned_event,
+ &mut progress,
+ ))
}
async fn connect_pending_session(
@@ -107,34 +216,135 @@ fn connect_request_for_target(
})
}
-async fn poll_pending_session(
+async fn poll_pending_session<F>(
record: &RadrootsAppRemoteSignerSessionRecord,
client_secret_key_hex: &str,
-) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> {
- let client_identity = RadrootsIdentity::from_secret_key_str(client_secret_key_hex)
- .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
- let target = RadrootsAppRemoteSignerTarget {
- source: crate::RadrootsAppRemoteSignerSource::BunkerUri,
- signer_identity: record.signer_identity.clone(),
- relays: record.relays.clone(),
- connect_secret: None,
- requested_permissions: crate::radroots_app_remote_signer_requested_permissions(),
- };
-
- match execute_request(
+ 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,
+ content: &str,
+ progress: &mut F,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
+where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+{
+ let user_identity = record.user_identity.as_ref().ok_or_else(|| {
+ RadrootsAppRemoteSignerError::ConnectFailed(
+ "remote signer session is missing the approved user identity".to_owned(),
+ )
+ })?;
+ let unsigned_event = EventBuilder::text_note(content.trim())
+ .build(parse_public_key_hex(user_identity.public_key_hex.as_str())?);
+ sign_unsigned_event(record, client_secret_key_hex, unsigned_event, progress).await
+}
+
+async fn sign_unsigned_event<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ unsigned_event: UnsignedEvent,
+ progress: &mut F,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
+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,
+ 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,
+ })
+ }
+ RadrootsNostrConnectResponse::Error { error, .. } => {
+ Err(RadrootsAppRemoteSignerError::ConnectFailed(error))
+ }
+ other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse {
+ method: RadrootsNostrConnectMethod::SignEvent,
+ response: format!("{other:?}"),
+ }),
+ }
+}
+
+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,
@@ -142,6 +352,28 @@ 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
+}
+
+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
@@ -178,7 +410,7 @@ async fn execute_request(
.map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
let response_method = method.clone();
- let response = timeout(request_timeout, async move {
+ let response = timeout(request_timeout, async {
loop {
let notification = match notifications.recv().await {
Ok(notification) => notification,
@@ -205,6 +437,9 @@ async fn execute_request(
&response_method,
request_id.as_str(),
)? {
+ Some(RadrootsNostrConnectResponse::AuthUrl(url)) => {
+ progress(RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { url });
+ }
Some(response) => return Ok(response),
None => continue,
}
@@ -284,9 +519,12 @@ fn classify_pending_poll_response(
) -> RadrootsAppRemoteSignerPendingPollOutcome {
match response.into_pending_connection_poll_outcome() {
RadrootsNostrConnectPendingConnectionPollOutcome::Approved(public_key) => {
- RadrootsAppRemoteSignerPendingPollOutcome::Approved(RadrootsIdentityPublic::new(
- public_key,
- ))
+ RadrootsAppRemoteSignerPendingPollOutcome::Approved(
+ RadrootsAppRemoteSignerApprovedSession {
+ user_identity: RadrootsIdentityPublic::new(public_key),
+ relays: Vec::new(),
+ },
+ )
}
RadrootsNostrConnectPendingConnectionPollOutcome::PendingApproval => {
RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval
@@ -296,9 +534,7 @@ fn classify_pending_poll_response(
}
RadrootsNostrConnectPendingConnectionPollOutcome::AuthChallenge { url } => {
RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
- message: format!(
- "remote signer requires an unsupported authorization challenge: {url}"
- ),
+ message: format!("unexpected remote signer authorization challenge: {url}"),
}
}
RadrootsNostrConnectPendingConnectionPollOutcome::UnexpectedResponse { response } => {
@@ -343,6 +579,25 @@ fn parse_public_key_hex(value: &str) -> Result<nostr::PublicKey, RadrootsAppRemo
.map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))
}
+fn load_client_identity(
+ client_secret_key_hex: &str,
+) -> Result<RadrootsIdentity, RadrootsAppRemoteSignerError> {
+ RadrootsIdentity::from_secret_key_str(client_secret_key_hex)
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))
+}
+
+fn target_for_record(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+) -> RadrootsAppRemoteSignerTarget {
+ RadrootsAppRemoteSignerTarget {
+ source: crate::RadrootsAppRemoteSignerSource::BunkerUri,
+ signer_identity: record.signer_identity.clone(),
+ relays: record.relays.clone(),
+ connect_secret: None,
+ requested_permissions: crate::radroots_app_remote_signer_requested_permissions(),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -399,8 +654,9 @@ mod tests {
assert!(matches!(
outcome,
- RadrootsAppRemoteSignerPendingPollOutcome::Approved(identity)
- if identity.public_key_hex == fixture_public_key().to_hex()
+ RadrootsAppRemoteSignerPendingPollOutcome::Approved(
+ RadrootsAppRemoteSignerApprovedSession { user_identity, .. }
+ ) if user_identity.public_key_hex == fixture_public_key().to_hex()
));
}
@@ -443,8 +699,23 @@ mod tests {
RadrootsNostrConnectRequest::Connect {
requested_permissions,
..
- } => assert_eq!(requested_permissions.to_string(), "sign_event:kind:1"),
+ } => assert_eq!(
+ requested_permissions.to_string(),
+ "sign_event:kind:1,switch_relays"
+ ),
other => panic!("unexpected request: {other:?}"),
}
}
+
+ #[test]
+ fn sign_kind1_note_output_carries_signed_relay_state() {
+ let signed_event = RadrootsAppRemoteSignerSignedEvent {
+ event_id_hex: "deadbeef".to_owned(),
+ event_json: "{\"id\":\"deadbeef\"}".to_owned(),
+ relays: vec!["ws://localhost:8080".to_owned()],
+ };
+
+ assert_eq!(signed_event.event_id_hex, "deadbeef");
+ assert_eq!(signed_event.relays, vec!["ws://localhost:8080".to_owned()]);
+ }
}
diff --git a/crates/remote-signer/src/session.rs b/crates/remote-signer/src/session.rs
@@ -164,6 +164,7 @@ impl RadrootsAppRemoteSignerSessionStoreState {
&mut self,
client_account_id: &str,
user_identity: RadrootsIdentityPublic,
+ relays: Vec<String>,
) -> Option<RadrootsAppRemoteSignerSessionRecord> {
let now = now_unix_secs();
self.sessions.retain(|record| {
@@ -175,6 +176,7 @@ impl RadrootsAppRemoteSignerSessionStoreState {
.iter_mut()
.find(|record| record.client_account_id() == client_account_id)?;
record.user_identity = Some(user_identity);
+ record.relays = relays;
record.status = RadrootsAppRemoteSignerSessionStatus::Active;
record.updated_at_unix = now;
Some(record.clone())
@@ -324,11 +326,19 @@ mod tests {
let alice_public = fixture_public(&FIXTURE_ALICE);
let active = state
- .activate_session(client_account_id.as_str(), alice_public.clone())
+ .activate_session(
+ client_account_id.as_str(),
+ alice_public.clone(),
+ vec!["wss://relay.updated.example".to_owned()],
+ )
.expect("active");
assert_eq!(active.status, RadrootsAppRemoteSignerSessionStatus::Active);
assert_eq!(active.account_id(), Some(alice_public.id.as_str()));
+ assert_eq!(
+ active.relays,
+ vec!["wss://relay.updated.example".to_owned()]
+ );
assert!(state.pending_session().is_none());
}
@@ -339,7 +349,11 @@ mod tests {
let client_account_id = pending.client_account_id().to_owned();
state.upsert_pending(pending).expect("pending");
let alice_public = fixture_public(&FIXTURE_ALICE);
- state.activate_session(client_account_id.as_str(), alice_public.clone());
+ state.activate_session(
+ client_account_id.as_str(),
+ alice_public.clone(),
+ vec!["wss://relay.updated.example".to_owned()],
+ );
let removed = state
.remove_active_session_for_account_id(alice_public.id.as_str())