app

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

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:
Mcrates/android/src/lib.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/android/src/remote_signer.rs | 166++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/core/src/lib.rs | 108++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/core/src/remote_signer.rs | 7+++++++
Mcrates/desktop/src/main.rs | 53++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/desktop/src/remote_signer.rs | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mcrates/ios/src/lib.rs | 27++++++++++++++++++++++++++-
Mcrates/ios/src/remote_signer.rs | 164++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Acrates/remote-signer/src/action.rs | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/remote-signer/src/controller.rs | 74+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mcrates/remote-signer/src/input.rs | 15+++++++++------
Mcrates/remote-signer/src/lib.rs | 16++++++++++++++--
Mcrates/remote-signer/src/protocol.rs | 323++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/remote-signer/src/session.rs | 18++++++++++++++++--
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())