app

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

commit 5995fba51bbfea93700a2985265065302f91dbbf
parent e615e0ad2e2178da1d4fcef387d42e5c07808519
Author: triesap <tyson@radroots.org>
Date:   Sat, 28 Mar 2026 17:49:31 +0000

app: share remote signer lifecycle core

- move pending-approval connect and poll flow into a shared remote-signer controller
- trim desktop ios and android wrappers to platform storage and account activation hooks
- write session store state through an atomic temp-file rename path
- recover invalid session store files by quarantining corrupt contents before reset to default

Diffstat:
Mcrates/android/src/remote_signer.rs | 242+++++++++++++++++++++++++------------------------------------------------------
Mcrates/desktop/src/remote_signer.rs | 246+++++++++++++++++++++++++------------------------------------------------------
Mcrates/ios/src/remote_signer.rs | 242+++++++++++++++++++++++++------------------------------------------------------
Acrates/remote-signer/src/controller.rs | 217+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/remote-signer/src/lib.rs | 2++
Mcrates/remote-signer/src/session.rs | 135++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
6 files changed, 571 insertions(+), 513 deletions(-)

diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -5,48 +5,94 @@ use radroots_app_core::{ RadrootsRemoteSignerPreview, SetupActionState, }; use radroots_app_remote_signer::{ - RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerSessionStoreState, - radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session, - radroots_app_remote_signer_preview, + RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, + RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, + RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview, }; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, }; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; const REMOTE_SIGNER_LABEL: &str = "remote signer"; -#[derive(Clone, Default)] +#[derive(Clone, Copy)] +struct AndroidRemoteSignerHooks; + +impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks { + type ReadyState = IdentityGateState; + + fn store_pending_session( + &self, + pending: &RadrootsAppRemoteSignerPendingSession, + ) -> Result<(), String> { + let client_account_id = pending.record.client_account_id().to_owned(); + store_client_secret( + client_account_id.as_str(), + pending.client_secret_key_hex.as_str(), + )?; + let store_path = sessions_path()?; + let mut state = load_sessions(store_path.as_path())?; + if let Err(error) = state.upsert_pending(pending.record.clone()) { + let _ = remove_client_secret(client_account_id.as_str()); + return Err(error.to_string()); + } + if let Err(error) = save_sessions(store_path.as_path(), &state) { + let _ = remove_client_secret(client_account_id.as_str()); + return Err(error); + } + Ok(()) + } + + fn pending_session_record( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + pending_session_record() + } + + fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String> { + load_client_secret(client_account_id) + } + + fn activate_pending_session( + &self, + client_account_id: &str, + user_identity: radroots_identity::RadrootsIdentityPublic, + ) -> Result<Self::ReadyState, String> { + activate_remote_session(client_account_id, user_identity) + } + + fn clear_pending_session( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + if let Some(session) = remove_pending_session()? { + remove_client_secret(session.client_account_id())?; + Ok(Some(session)) + } else { + Ok(None) + } + } +} + +#[derive(Clone)] pub(crate) struct AndroidRemoteSigner { - update: Arc<Mutex<Option<Result<Option<IdentityGateState>, String>>>>, - changed: Arc<AtomicBool>, - connecting: Arc<AtomicBool>, - polling: Arc<AtomicBool>, + controller: RadrootsAppRemoteSignerController<AndroidRemoteSignerHooks>, } impl AndroidRemoteSigner { pub(crate) fn new() -> Self { - let tracker = Self::default(); - if let Err(error) = tracker.resume_pending() { - tracker.push_update(Err(error)); + Self { + controller: RadrootsAppRemoteSignerController::new(AndroidRemoteSignerHooks), } - tracker } pub(crate) fn take_update(&self) -> Option<Result<Option<IdentityGateState>, String>> { - if !self.changed.swap(false, Ordering::AcqRel) { - return None; - } - - self.update.lock().ok().and_then(|mut slot| slot.take()) + self.controller.take_update() } pub(crate) fn is_connecting(&self) -> bool { - self.connecting.load(Ordering::Acquire) + self.controller.is_connecting() } pub(crate) fn action_state(&self) -> Result<SetupActionState, String> { @@ -74,137 +120,7 @@ impl AndroidRemoteSigner { } pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> { - if self.connecting.swap(true, Ordering::AcqRel) { - return Err("remote signer connection is already starting".to_owned()); - } - - if pending_connection()?.is_some() { - self.connecting.store(false, Ordering::Release); - return Err("a remote signer connection is already pending approval".to_owned()); - } - - if let Ok(mut slot) = self.update.lock() { - *slot = None; - } - - let tracker = self.clone(); - let input = input.to_owned(); - std::thread::spawn(move || { - let outcome = (|| -> Result<(), String> { - let pending = radroots_app_remote_signer_connect_pending(input.as_str()) - .map_err(|error| error.to_string())?; - let client_account_id = pending.record.client_account_id().to_owned(); - store_client_secret( - client_account_id.as_str(), - pending.client_secret_key_hex.as_str(), - )?; - let store_path = sessions_path()?; - let mut state = load_sessions(store_path.as_path())?; - state - .upsert_pending(pending.record.clone()) - .map_err(|error| error.to_string())?; - save_sessions(store_path.as_path(), &state)?; - tracker.start_polling( - pending.record.client_account_id().to_owned(), - pending.client_secret_key_hex, - ); - Ok(()) - })(); - - if let Err(error) = outcome { - tracker.push_update(Err(error)); - } - tracker.connecting.store(false, Ordering::Release); - }); - - Ok(()) - } - - pub(crate) fn resume_pending(&self) -> Result<(), String> { - let Some(record) = pending_session_record()? else { - return Ok(()); - }; - let client_secret_key_hex = load_client_secret(record.client_account_id())?; - self.start_polling(record.client_account_id().to_owned(), client_secret_key_hex); - Ok(()) - } - - fn start_polling(&self, client_account_id: String, client_secret_key_hex: String) { - if self.polling.swap(true, Ordering::AcqRel) { - return; - } - - let tracker = self.clone(); - std::thread::spawn(move || { - loop { - let pending_record = match pending_session_record() { - Ok(Some(record)) if record.client_account_id() == client_account_id => record, - Ok(Some(_)) | Ok(None) => { - tracker.polling.store(false, Ordering::Release); - return; - } - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - }; - - match radroots_app_remote_signer_poll_pending_session( - &pending_record, - client_secret_key_hex.as_str(), - ) - .map_err(|error| error.to_string()) - { - Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) - | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => { - std::thread::sleep(Duration::from_secs(1)); - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => { - let ready_state = match activate_remote_session( - pending_record.client_account_id(), - user_identity, - ) { - Ok(state) => state, - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - }; - tracker.push_update(Ok(Some(ready_state))); - tracker.polling.store(false, Ordering::Release); - return; - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => { - let _ = remove_pending_session(); - let _ = remove_client_secret(client_account_id.as_str()); - tracker.push_update(Err(message)); - tracker.polling.store(false, Ordering::Release); - return; - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => { - let _ = remove_pending_session(); - let _ = remove_client_secret(client_account_id.as_str()); - tracker.push_update(Err(message)); - tracker.polling.store(false, Ordering::Release); - return; - } - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - } - } - }); - } - - fn push_update(&self, result: Result<Option<IdentityGateState>, String>) { - if let Ok(mut slot) = self.update.lock() { - *slot = Some(result); - self.changed.store(true, Ordering::Release); - } + self.controller.begin_connect(input) } } @@ -220,12 +136,12 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> { - Ok( - pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection { + 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( @@ -279,9 +195,7 @@ pub(crate) fn disconnect_selected_remote_signer( } pub(crate) fn cancel_pending_connection() -> Result<(), String> { - if let Some(session) = remove_pending_session()? { - remove_client_secret(session.client_account_id())?; - } + let _ = AndroidRemoteSignerHooks.clear_pending_session()?; Ok(()) } @@ -308,8 +222,7 @@ fn activate_remote_session( }) } -fn pending_session_record() --> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let state = load_sessions(store_path.as_path())?; Ok(state.pending_session().cloned()) @@ -317,14 +230,13 @@ fn pending_session_record() fn active_session_for_account_id( account_id: &str, -) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let state = load_sessions(store_path.as_path())?; Ok(state.active_session_for_account_id(account_id).cloned()) } -fn remove_pending_session() --> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +fn remove_pending_session() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let mut state = load_sessions(store_path.as_path())?; let removed = state.remove_pending_session(); @@ -334,7 +246,7 @@ fn remove_pending_session() fn remove_active_session( account_id: &str, -) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let mut state = load_sessions(store_path.as_path())?; let removed = state.remove_active_session_for_account_id(account_id); diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs @@ -5,47 +5,93 @@ use radroots_app_core::{ RadrootsRemoteSignerPreview, SetupActionState, }; use radroots_app_remote_signer::{ - RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerSessionStoreState, - radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session, - radroots_app_remote_signer_preview, + RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, + RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, + RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview, }; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, }; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; const REMOTE_SIGNER_LABEL: &str = "remote signer"; -#[derive(Clone, Default)] +#[derive(Clone, Copy)] +struct DesktopRemoteSignerHooks; + +impl RadrootsAppRemoteSignerControllerHooks for DesktopRemoteSignerHooks { + type ReadyState = IdentityGateState; + + fn store_pending_session( + &self, + pending: &RadrootsAppRemoteSignerPendingSession, + ) -> Result<(), String> { + let client_account_id = pending.record.client_account_id().to_owned(); + store_client_secret( + client_account_id.as_str(), + pending.client_secret_key_hex.as_str(), + )?; + let store_path = sessions_path()?; + let mut state = load_sessions(store_path.as_path())?; + if let Err(error) = state.upsert_pending(pending.record.clone()) { + let _ = remove_client_secret(client_account_id.as_str()); + return Err(error.to_string()); + } + if let Err(error) = save_sessions(store_path.as_path(), &state) { + let _ = remove_client_secret(client_account_id.as_str()); + return Err(error); + } + Ok(()) + } + + fn pending_session_record( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + pending_session_record() + } + + fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String> { + load_client_secret(client_account_id) + } + + fn activate_pending_session( + &self, + client_account_id: &str, + user_identity: radroots_identity::RadrootsIdentityPublic, + ) -> Result<Self::ReadyState, String> { + activate_remote_session(client_account_id, user_identity) + } + + fn clear_pending_session( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + if let Some(session) = remove_pending_session()? { + remove_client_secret(session.client_account_id())?; + Ok(Some(session)) + } else { + Ok(None) + } + } +} + +#[derive(Clone)] pub(crate) struct DesktopRemoteSigner { - update: Arc<Mutex<Option<Result<Option<IdentityGateState>, String>>>>, - changed: Arc<AtomicBool>, - connecting: Arc<AtomicBool>, - polling: Arc<AtomicBool>, + controller: RadrootsAppRemoteSignerController<DesktopRemoteSignerHooks>, } impl DesktopRemoteSigner { pub(crate) fn new() -> Self { - let tracker = Self::default(); - if let Err(error) = tracker.resume_pending() { - tracker.push_update(Err(error)); + Self { + controller: RadrootsAppRemoteSignerController::new(DesktopRemoteSignerHooks), } - tracker } pub(crate) fn take_update(&self) -> Option<Result<Option<IdentityGateState>, String>> { - if !self.changed.swap(false, Ordering::AcqRel) { - return None; - } - - self.update.lock().ok().and_then(|mut slot| slot.take()) + self.controller.take_update() } pub(crate) fn is_connecting(&self) -> bool { - self.connecting.load(Ordering::Acquire) + self.controller.is_connecting() } pub(crate) fn action_state(&self) -> Result<SetupActionState, String> { @@ -73,141 +119,7 @@ impl DesktopRemoteSigner { } pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> { - if self.connecting.swap(true, Ordering::AcqRel) { - return Err("remote signer connection is already starting".to_owned()); - } - - if pending_connection()?.is_some() { - self.connecting.store(false, Ordering::Release); - return Err("a remote signer connection is already pending approval".to_owned()); - } - - if let Ok(mut slot) = self.update.lock() { - *slot = None; - } - - let tracker = self.clone(); - let input = input.to_owned(); - std::thread::spawn(move || { - let outcome = (|| -> Result<(), String> { - let pending = radroots_app_remote_signer_connect_pending(input.as_str()) - .map_err(|error| error.to_string())?; - let client_account_id = pending.record.client_account_id().to_owned(); - store_client_secret( - client_account_id.as_str(), - pending.client_secret_key_hex.as_str(), - )?; - let store_path = sessions_path()?; - let mut state = load_sessions(store_path.as_path())?; - state - .upsert_pending(pending.record.clone()) - .map_err(|error| error.to_string())?; - save_sessions(store_path.as_path(), &state)?; - tracker.start_polling( - pending.record.client_account_id().to_owned(), - pending.client_secret_key_hex, - ); - Ok(()) - })(); - - if let Err(error) = outcome { - tracker.push_update(Err(error)); - } - tracker.connecting.store(false, Ordering::Release); - }); - - Ok(()) - } - - pub(crate) fn resume_pending(&self) -> Result<(), String> { - let Some(record) = pending_session_record()? else { - return Ok(()); - }; - let client_secret_key_hex = load_client_secret(record.client_account_id())?; - self.start_polling(record.client_account_id().to_owned(), client_secret_key_hex); - Ok(()) - } - - fn start_polling(&self, client_account_id: String, client_secret_key_hex: String) { - if self.polling.swap(true, Ordering::AcqRel) { - return; - } - - let tracker = self.clone(); - std::thread::spawn(move || { - loop { - let pending_record = match pending_session_record() { - Ok(Some(record)) if record.client_account_id() == client_account_id => record, - Ok(Some(_)) => { - tracker.polling.store(false, Ordering::Release); - return; - } - Ok(None) => { - tracker.polling.store(false, Ordering::Release); - return; - } - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - }; - - match radroots_app_remote_signer_poll_pending_session( - &pending_record, - client_secret_key_hex.as_str(), - ) - .map_err(|error| error.to_string()) - { - Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) - | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => { - std::thread::sleep(Duration::from_secs(1)); - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => { - let ready_state = match activate_remote_session( - pending_record.client_account_id(), - user_identity, - ) { - Ok(state) => state, - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - }; - tracker.push_update(Ok(Some(ready_state))); - tracker.polling.store(false, Ordering::Release); - return; - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => { - let _ = remove_pending_session(); - let _ = remove_client_secret(client_account_id.as_str()); - tracker.push_update(Err(message)); - tracker.polling.store(false, Ordering::Release); - return; - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => { - let _ = remove_pending_session(); - let _ = remove_client_secret(client_account_id.as_str()); - tracker.push_update(Err(message)); - tracker.polling.store(false, Ordering::Release); - return; - } - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - } - } - }); - } - - fn push_update(&self, result: Result<Option<IdentityGateState>, String>) { - if let Ok(mut slot) = self.update.lock() { - *slot = Some(result); - self.changed.store(true, Ordering::Release); - } + self.controller.begin_connect(input) } } @@ -223,12 +135,12 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> { - Ok( - pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection { + 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( @@ -282,9 +194,7 @@ pub(crate) fn disconnect_selected_remote_signer( } pub(crate) fn cancel_pending_connection() -> Result<(), String> { - if let Some(session) = remove_pending_session()? { - remove_client_secret(session.client_account_id())?; - } + let _ = DesktopRemoteSignerHooks.clear_pending_session()?; Ok(()) } @@ -311,8 +221,7 @@ fn activate_remote_session( }) } -fn pending_session_record() --> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let state = load_sessions(store_path.as_path())?; Ok(state.pending_session().cloned()) @@ -320,14 +229,13 @@ fn pending_session_record() fn active_session_for_account_id( account_id: &str, -) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let state = load_sessions(store_path.as_path())?; Ok(state.active_session_for_account_id(account_id).cloned()) } -fn remove_pending_session() --> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +fn remove_pending_session() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let mut state = load_sessions(store_path.as_path())?; let removed = state.remove_pending_session(); @@ -337,7 +245,7 @@ fn remove_pending_session() fn remove_active_session( account_id: &str, -) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let mut state = load_sessions(store_path.as_path())?; let removed = state.remove_active_session_for_account_id(account_id); diff --git a/crates/ios/src/remote_signer.rs b/crates/ios/src/remote_signer.rs @@ -5,48 +5,94 @@ use radroots_app_core::{ RadrootsRemoteSignerPreview, SetupActionState, }; use radroots_app_remote_signer::{ - RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerSessionStoreState, - radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session, - radroots_app_remote_signer_preview, + RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, + RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, + RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview, }; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, }; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; const REMOTE_SIGNER_LABEL: &str = "remote signer"; -#[derive(Clone, Default)] +#[derive(Clone, Copy)] +struct IosRemoteSignerHooks; + +impl RadrootsAppRemoteSignerControllerHooks for IosRemoteSignerHooks { + type ReadyState = IdentityGateState; + + fn store_pending_session( + &self, + pending: &RadrootsAppRemoteSignerPendingSession, + ) -> Result<(), String> { + let client_account_id = pending.record.client_account_id().to_owned(); + store_client_secret( + client_account_id.as_str(), + pending.client_secret_key_hex.as_str(), + )?; + let store_path = sessions_path()?; + let mut state = load_sessions(store_path.as_path())?; + if let Err(error) = state.upsert_pending(pending.record.clone()) { + let _ = remove_client_secret(client_account_id.as_str()); + return Err(error.to_string()); + } + if let Err(error) = save_sessions(store_path.as_path(), &state) { + let _ = remove_client_secret(client_account_id.as_str()); + return Err(error); + } + Ok(()) + } + + fn pending_session_record( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + pending_session_record() + } + + fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String> { + load_client_secret(client_account_id) + } + + fn activate_pending_session( + &self, + client_account_id: &str, + user_identity: radroots_identity::RadrootsIdentityPublic, + ) -> Result<Self::ReadyState, String> { + activate_remote_session(client_account_id, user_identity) + } + + fn clear_pending_session( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + if let Some(session) = remove_pending_session()? { + remove_client_secret(session.client_account_id())?; + Ok(Some(session)) + } else { + Ok(None) + } + } +} + +#[derive(Clone)] pub(crate) struct IosRemoteSigner { - update: Arc<Mutex<Option<Result<Option<IdentityGateState>, String>>>>, - changed: Arc<AtomicBool>, - connecting: Arc<AtomicBool>, - polling: Arc<AtomicBool>, + controller: RadrootsAppRemoteSignerController<IosRemoteSignerHooks>, } impl IosRemoteSigner { pub(crate) fn new() -> Self { - let tracker = Self::default(); - if let Err(error) = tracker.resume_pending() { - tracker.push_update(Err(error)); + Self { + controller: RadrootsAppRemoteSignerController::new(IosRemoteSignerHooks), } - tracker } pub(crate) fn take_update(&self) -> Option<Result<Option<IdentityGateState>, String>> { - if !self.changed.swap(false, Ordering::AcqRel) { - return None; - } - - self.update.lock().ok().and_then(|mut slot| slot.take()) + self.controller.take_update() } pub(crate) fn is_connecting(&self) -> bool { - self.connecting.load(Ordering::Acquire) + self.controller.is_connecting() } pub(crate) fn action_state(&self) -> Result<SetupActionState, String> { @@ -74,137 +120,7 @@ impl IosRemoteSigner { } pub(crate) fn begin_connect(&self, input: &str) -> Result<(), String> { - if self.connecting.swap(true, Ordering::AcqRel) { - return Err("remote signer connection is already starting".to_owned()); - } - - if pending_connection()?.is_some() { - self.connecting.store(false, Ordering::Release); - return Err("a remote signer connection is already pending approval".to_owned()); - } - - if let Ok(mut slot) = self.update.lock() { - *slot = None; - } - - let tracker = self.clone(); - let input = input.to_owned(); - std::thread::spawn(move || { - let outcome = (|| -> Result<(), String> { - let pending = radroots_app_remote_signer_connect_pending(input.as_str()) - .map_err(|error| error.to_string())?; - let client_account_id = pending.record.client_account_id().to_owned(); - store_client_secret( - client_account_id.as_str(), - pending.client_secret_key_hex.as_str(), - )?; - let store_path = sessions_path()?; - let mut state = load_sessions(store_path.as_path())?; - state - .upsert_pending(pending.record.clone()) - .map_err(|error| error.to_string())?; - save_sessions(store_path.as_path(), &state)?; - tracker.start_polling( - pending.record.client_account_id().to_owned(), - pending.client_secret_key_hex, - ); - Ok(()) - })(); - - if let Err(error) = outcome { - tracker.push_update(Err(error)); - } - tracker.connecting.store(false, Ordering::Release); - }); - - Ok(()) - } - - pub(crate) fn resume_pending(&self) -> Result<(), String> { - let Some(record) = pending_session_record()? else { - return Ok(()); - }; - let client_secret_key_hex = load_client_secret(record.client_account_id())?; - self.start_polling(record.client_account_id().to_owned(), client_secret_key_hex); - Ok(()) - } - - fn start_polling(&self, client_account_id: String, client_secret_key_hex: String) { - if self.polling.swap(true, Ordering::AcqRel) { - return; - } - - let tracker = self.clone(); - std::thread::spawn(move || { - loop { - let pending_record = match pending_session_record() { - Ok(Some(record)) if record.client_account_id() == client_account_id => record, - Ok(Some(_)) | Ok(None) => { - tracker.polling.store(false, Ordering::Release); - return; - } - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - }; - - match radroots_app_remote_signer_poll_pending_session( - &pending_record, - client_secret_key_hex.as_str(), - ) - .map_err(|error| error.to_string()) - { - Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) - | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => { - std::thread::sleep(Duration::from_secs(1)); - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => { - let ready_state = match activate_remote_session( - pending_record.client_account_id(), - user_identity, - ) { - Ok(state) => state, - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - }; - tracker.push_update(Ok(Some(ready_state))); - tracker.polling.store(false, Ordering::Release); - return; - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) => { - let _ = remove_pending_session(); - let _ = remove_client_secret(client_account_id.as_str()); - tracker.push_update(Err(message)); - tracker.polling.store(false, Ordering::Release); - return; - } - Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => { - let _ = remove_pending_session(); - let _ = remove_client_secret(client_account_id.as_str()); - tracker.push_update(Err(message)); - tracker.polling.store(false, Ordering::Release); - return; - } - Err(error) => { - tracker.push_update(Err(error)); - tracker.polling.store(false, Ordering::Release); - return; - } - } - } - }); - } - - fn push_update(&self, result: Result<Option<IdentityGateState>, String>) { - if let Ok(mut slot) = self.update.lock() { - *slot = Some(result); - self.changed.store(true, Ordering::Release); - } + self.controller.begin_connect(input) } } @@ -220,12 +136,12 @@ pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPrev pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> { - Ok( - pending_session_record()?.map(|record| RadrootsPendingRemoteSignerConnection { + 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( @@ -279,9 +195,7 @@ pub(crate) fn disconnect_selected_remote_signer( } pub(crate) fn cancel_pending_connection() -> Result<(), String> { - if let Some(session) = remove_pending_session()? { - remove_client_secret(session.client_account_id())?; - } + let _ = IosRemoteSignerHooks.clear_pending_session()?; Ok(()) } @@ -308,8 +222,7 @@ fn activate_remote_session( }) } -fn pending_session_record() --> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +fn pending_session_record() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let state = load_sessions(store_path.as_path())?; Ok(state.pending_session().cloned()) @@ -317,14 +230,13 @@ fn pending_session_record() fn active_session_for_account_id( account_id: &str, -) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let state = load_sessions(store_path.as_path())?; Ok(state.active_session_for_account_id(account_id).cloned()) } -fn remove_pending_session() --> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +fn remove_pending_session() -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let mut state = load_sessions(store_path.as_path())?; let removed = state.remove_pending_session(); @@ -334,7 +246,7 @@ fn remove_pending_session() fn remove_active_session( account_id: &str, -) -> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { let store_path = sessions_path()?; let mut state = load_sessions(store_path.as_path())?; let removed = state.remove_active_session_for_account_id(account_id); diff --git a/crates/remote-signer/src/controller.rs b/crates/remote-signer/src/controller.rs @@ -0,0 +1,217 @@ +use crate::protocol::{ + RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerPendingSession, + radroots_app_remote_signer_connect_pending, radroots_app_remote_signer_poll_pending_session, +}; +use crate::session::RadrootsAppRemoteSignerSessionRecord; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +pub trait RadrootsAppRemoteSignerControllerHooks: Clone + Send + Sync + 'static { + type ReadyState: Send + 'static; + + fn store_pending_session( + &self, + pending: &RadrootsAppRemoteSignerPendingSession, + ) -> Result<(), String>; + + fn pending_session_record( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String>; + + fn load_pending_client_secret(&self, client_account_id: &str) -> Result<String, String>; + + fn activate_pending_session( + &self, + client_account_id: &str, + user_identity: radroots_identity::RadrootsIdentityPublic, + ) -> Result<Self::ReadyState, String>; + + fn clear_pending_session(&self) + -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String>; +} + +pub struct RadrootsAppRemoteSignerController<H> +where + H: RadrootsAppRemoteSignerControllerHooks, +{ + hooks: H, + update: Arc<Mutex<Option<Result<Option<H::ReadyState>, String>>>>, + changed: Arc<AtomicBool>, + connecting: Arc<AtomicBool>, + polling: Arc<AtomicBool>, + _ready_state: PhantomData<H::ReadyState>, +} + +impl<H> Clone for RadrootsAppRemoteSignerController<H> +where + H: RadrootsAppRemoteSignerControllerHooks, +{ + fn clone(&self) -> Self { + Self { + hooks: self.hooks.clone(), + update: Arc::clone(&self.update), + changed: Arc::clone(&self.changed), + connecting: Arc::clone(&self.connecting), + polling: Arc::clone(&self.polling), + _ready_state: PhantomData, + } + } +} + +impl<H> RadrootsAppRemoteSignerController<H> +where + H: RadrootsAppRemoteSignerControllerHooks, +{ + pub fn new(hooks: H) -> Self { + let controller = Self { + hooks, + update: Arc::new(Mutex::new(None)), + changed: Arc::new(AtomicBool::new(false)), + connecting: Arc::new(AtomicBool::new(false)), + polling: Arc::new(AtomicBool::new(false)), + _ready_state: PhantomData, + }; + if let Err(error) = controller.resume_pending() { + controller.push_update(Err(error)); + } + controller + } + + 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_connecting(&self) -> bool { + self.connecting.load(Ordering::Acquire) + } + + pub fn begin_connect(&self, input: &str) -> Result<(), String> { + if self.connecting.swap(true, Ordering::AcqRel) { + return Err("remote signer connection is already starting".to_owned()); + } + + if self.pending_session_record()?.is_some() { + self.connecting.store(false, Ordering::Release); + return Err("a remote signer connection is already pending approval".to_owned()); + } + + if let Ok(mut slot) = self.update.lock() { + *slot = None; + } + + let tracker = self.clone(); + let input = input.to_owned(); + std::thread::spawn(move || { + let outcome = (|| -> Result<(), String> { + let pending = radroots_app_remote_signer_connect_pending(input.as_str()) + .map_err(|error| error.to_string())?; + let client_account_id = pending.record.client_account_id().to_owned(); + let client_secret_key_hex = pending.client_secret_key_hex.clone(); + tracker.hooks.store_pending_session(&pending)?; + tracker.start_polling(client_account_id, client_secret_key_hex); + Ok(()) + })(); + + if let Err(error) = outcome { + tracker.push_update(Err(error)); + } + tracker.connecting.store(false, Ordering::Release); + }); + + Ok(()) + } + + pub fn pending_session_record( + &self, + ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + self.hooks.pending_session_record() + } + + fn resume_pending(&self) -> Result<(), String> { + let Some(record) = self.pending_session_record()? else { + return Ok(()); + }; + let client_secret_key_hex = self + .hooks + .load_pending_client_secret(record.client_account_id())?; + self.start_polling(record.client_account_id().to_owned(), client_secret_key_hex); + Ok(()) + } + + fn start_polling(&self, client_account_id: String, client_secret_key_hex: String) { + if self.polling.swap(true, Ordering::AcqRel) { + return; + } + + let tracker = self.clone(); + std::thread::spawn(move || { + loop { + let pending_record = match tracker.hooks.pending_session_record() { + Ok(Some(record)) if record.client_account_id() == client_account_id => record, + Ok(Some(_)) | Ok(None) => { + tracker.polling.store(false, Ordering::Release); + return; + } + Err(error) => { + tracker.push_update(Err(error)); + tracker.polling.store(false, Ordering::Release); + return; + } + }; + + match radroots_app_remote_signer_poll_pending_session( + &pending_record, + client_secret_key_hex.as_str(), + ) + .map_err(|error| error.to_string()) + { + Ok(RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval) + | Ok(RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { .. }) => { + std::thread::sleep(Duration::from_secs(1)); + } + Ok(RadrootsAppRemoteSignerPendingPollOutcome::Approved(user_identity)) => { + let ready_state = match tracker.hooks.activate_pending_session( + pending_record.client_account_id(), + user_identity, + ) { + Ok(state) => state, + Err(error) => { + tracker.push_update(Err(error)); + tracker.polling.store(false, Ordering::Release); + return; + } + }; + tracker.push_update(Ok(Some(ready_state))); + tracker.polling.store(false, Ordering::Release); + return; + } + Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) + | Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => { + let _ = tracker.hooks.clear_pending_session(); + tracker.push_update(Err(message)); + tracker.polling.store(false, Ordering::Release); + return; + } + Err(error) => { + tracker.push_update(Err(error)); + tracker.polling.store(false, Ordering::Release); + return; + } + } + } + }); + } + + 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); + } + } +} diff --git a/crates/remote-signer/src/lib.rs b/crates/remote-signer/src/lib.rs @@ -1,10 +1,12 @@ #![forbid(unsafe_code)] +mod controller; mod error; mod input; mod protocol; mod session; +pub use controller::{RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks}; pub use error::RadrootsAppRemoteSignerError; pub use input::{ RadrootsAppRemoteSignerSource, RadrootsAppRemoteSignerTarget, diff --git a/crates/remote-signer/src/session.rs b/crates/remote-signer/src/session.rs @@ -1,6 +1,7 @@ use crate::error::RadrootsAppRemoteSignerError; use radroots_identity::RadrootsIdentityPublic; use serde::{Deserialize, Serialize}; +use std::io::Write; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -70,19 +71,8 @@ impl RadrootsAppRemoteSignerSessionRecord { impl RadrootsAppRemoteSignerSessionStoreState { pub fn load(path: &Path) -> Result<Self, RadrootsAppRemoteSignerError> { - match std::fs::read_to_string(path) { - Ok(contents) => { - let state: Self = serde_json::from_str(&contents).map_err(|error| { - RadrootsAppRemoteSignerError::InvalidSessionStore(error.to_string()) - })?; - if state.version != RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION { - return Err(RadrootsAppRemoteSignerError::InvalidSessionStore(format!( - "unsupported schema version {}", - state.version - ))); - } - Ok(state) - } + match std::fs::read(path) { + Ok(contents) => Self::load_bytes(path, contents), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), Err(error) => Err(RadrootsAppRemoteSignerError::SessionStoreIo( error.to_string(), @@ -97,7 +87,30 @@ impl RadrootsAppRemoteSignerSessionStoreState { } let json = serde_json::to_string_pretty(self) .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; - std::fs::write(path, json) + let temp_path = temporary_store_path(path); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(temp_path.as_path()) + .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; + if let Err(error) = (|| -> Result<(), std::io::Error> { + file.write_all(json.as_bytes())?; + file.flush()?; + file.sync_all() + })() { + let _ = std::fs::remove_file(temp_path.as_path()); + return Err(RadrootsAppRemoteSignerError::SessionStoreIo( + error.to_string(), + )); + } + + #[cfg(windows)] + if path.exists() { + std::fs::remove_file(path) + .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; + } + + std::fs::rename(temp_path.as_path(), path) .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string())) } @@ -167,6 +180,42 @@ impl RadrootsAppRemoteSignerSessionStoreState { })?; Some(self.sessions.remove(index)) } + + fn load_bytes(path: &Path, contents: Vec<u8>) -> Result<Self, RadrootsAppRemoteSignerError> { + let contents = String::from_utf8(contents).map_err(|error| { + RadrootsAppRemoteSignerError::InvalidSessionStore(format!( + "session store was not valid utf-8: {error}" + )) + }); + + let contents = match contents { + Ok(contents) => contents, + Err(error) => { + quarantine_invalid_store(path)?; + let _ = error; + return Ok(Self::default()); + } + }; + + let state: Self = serde_json::from_str(&contents) + .map_err(|error| RadrootsAppRemoteSignerError::InvalidSessionStore(error.to_string())); + + let state = match state { + Ok(state) => state, + Err(error) => { + quarantine_invalid_store(path)?; + let _ = error; + return Ok(Self::default()); + } + }; + + if state.version != RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION { + quarantine_invalid_store(path)?; + return Ok(Self::default()); + } + + Ok(state) + } } fn now_unix_secs() -> u64 { @@ -176,6 +225,31 @@ fn now_unix_secs() -> u64 { .unwrap_or(0) } +fn temporary_store_path(path: &Path) -> std::path::PathBuf { + let process_id = std::process::id(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + path.with_extension(format!("json.tmp-{process_id}-{timestamp}")) +} + +fn quarantine_invalid_store(path: &Path) -> Result<(), RadrootsAppRemoteSignerError> { + let process_id = std::process::id(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("remote-signer-sessions.json"); + let quarantine_path = + path.with_file_name(format!("{file_name}.corrupt-{timestamp}-{process_id}")); + std::fs::rename(path, quarantine_path.as_path()) + .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string())) +} + #[cfg(test)] mod tests { use super::*; @@ -245,4 +319,37 @@ mod tests { assert_eq!(removed.account_id(), Some(alice_public.id.as_str())); assert!(state.sessions.is_empty()); } + + #[test] + fn load_recovers_from_invalid_json_by_quarantining_store() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sessions.json"); + std::fs::write(path.as_path(), "{invalid").expect("write invalid"); + + let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load"); + + assert!(loaded.sessions.is_empty()); + assert!(!path.exists()); + let quarantined = std::fs::read_dir(temp.path()) + .expect("read dir") + .filter_map(|entry| entry.ok()) + .any(|entry| entry.file_name().to_string_lossy().contains("corrupt")); + assert!(quarantined); + } + + #[test] + fn load_recovers_from_unsupported_schema_version() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sessions.json"); + std::fs::write(path.as_path(), r#"{"version":999,"sessions":[]}"#).expect("write invalid"); + + let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load"); + + assert_eq!( + loaded.version, + RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION + ); + assert!(loaded.sessions.is_empty()); + assert!(!path.exists()); + } }