app

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

commit 96bb9c4918f0fac563830f7a6d3ca887dd17f069
parent 8c44bd9ee9e4f0a2a2cf679e548b6d385801120a
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 21:17:19 +0000

desktop: add remote signer backend

- add a macos remote signer session layer that previews bunker urls, persists pending sessions, and stores the client transport secret in apple keychain
- overlay remote signer readiness and custody on top of public-only nostr accounts so remote accounts appear in the roster and can be selected
- add connect cancel disconnect flows for remote signer accounts without exposing local secret actions for external custody
- resume pending approval polling on startup and surface remote signer identity updates back into core through the existing poll contract

Diffstat:
MCargo.lock | 1+
Mcrates/desktop/Cargo.toml | 1+
Mcrates/desktop/src/main.rs | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Acrates/desktop/src/remote_signer.rs | 403+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 569 insertions(+), 29 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3085,6 +3085,7 @@ dependencies = [ "objc2-foundation 0.3.2", "radroots-app-apple-security", "radroots-app-core", + "radroots-app-remote-signer", "radroots-app-test-support", "radroots-geocoder", "radroots-identity", diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml @@ -21,6 +21,7 @@ egui.workspace = true image.workspace = true log.workspace = true radroots-app-core = { path = "../core" } +radroots-app-remote-signer = { path = "../remote-signer" } radroots-geocoder.workspace = true radroots-nostr-accounts.workspace = true zeroize.workspace = true diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs @@ -32,10 +32,14 @@ use zeroize::Zeroizing; mod country_lookup; mod offline_geocoder; +#[cfg(target_os = "macos")] +mod remote_signer; mod reverse_lookup; use country_lookup::DesktopCountryLookup; use offline_geocoder::DesktopOfflineGeocoder; +#[cfg(target_os = "macos")] +use remote_signer::DesktopRemoteSigner; use reverse_lookup::DesktopReverseLookup; const RADROOTS_DESKTOP_ICON_BYTES: &[u8] = include_bytes!("../assets/icons/radroots-logo.ico"); @@ -67,6 +71,8 @@ fn desktop_icon() -> Option<egui::IconData> { struct DesktopBackend { country_lookup: DesktopCountryLookup, offline_geocoder: DesktopOfflineGeocoder, + #[cfg(target_os = "macos")] + remote_signer: DesktopRemoteSigner, reverse_lookup: DesktopReverseLookup, } @@ -95,6 +101,8 @@ impl DesktopBackend { Self { country_lookup: DesktopCountryLookup::new(), offline_geocoder, + #[cfg(target_os = "macos")] + remote_signer: DesktopRemoteSigner::new(), reverse_lookup: DesktopReverseLookup::new(), } } @@ -178,11 +186,12 @@ impl DesktopBackend { .map_err(|source| source.to_string())? .into_iter() .map(|record| { + let custody = remote_signer::custody_for_account_id(record.account_id.as_str())?; Ok(RadrootsAccountSummary { account_id: record.account_id.to_string(), npub: record.public_identity.public_key_npub, label: record.label, - custody: RadrootsAccountCustody::LocalManaged, + custody, }) }) .collect() @@ -366,7 +375,7 @@ impl RadrootsAppBackend for DesktopBackend { let status = manager .selected_account_status() .map_err(|source| source.to_string())?; - return Ok(Self::map_status(status)); + return remote_signer::identity_state_from_status(status); } #[cfg(not(target_os = "macos"))] @@ -624,6 +633,85 @@ impl RadrootsAppBackend for DesktopBackend { } } + fn remote_signer_action_state(&self) -> Option<SetupActionState> { + #[cfg(target_os = "macos")] + { + return Some( + self.remote_signer + .action_state() + .unwrap_or_else(|_| SetupActionState { + label: "Connect Remote Signer".to_owned(), + enabled: !self.remote_signer.is_connecting(), + pending: self.remote_signer.is_connecting(), + }), + ); + } + + #[cfg(not(target_os = "macos"))] + { + None + } + } + + fn preview_remote_signer_connection( + &self, + input: &str, + ) -> Result<radroots_app_core::RadrootsRemoteSignerPreview, String> { + #[cfg(target_os = "macos")] + { + return remote_signer::preview_connection(input); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = input; + Err("remote signer onboarding is not available in this build".to_owned()) + } + } + + fn request_remote_signer_connection( + &self, + input: &str, + ) -> Result<Option<IdentityGateState>, String> { + #[cfg(target_os = "macos")] + { + self.remote_signer.begin_connect(input)?; + return Ok(None); + } + + #[cfg(not(target_os = "macos"))] + { + let _ = input; + Ok(None) + } + } + + fn pending_remote_signer_connection( + &self, + ) -> Result<Option<radroots_app_core::RadrootsPendingRemoteSignerConnection>, String> { + #[cfg(target_os = "macos")] + { + return remote_signer::pending_connection(); + } + + #[cfg(not(target_os = "macos"))] + { + Ok(None) + } + } + + fn request_cancel_pending_remote_signer_connection(&self) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + return remote_signer::cancel_pending_connection(); + } + + #[cfg(not(target_os = "macos"))] + { + Ok(()) + } + } + fn request_select_account( &self, account_id: &str, @@ -649,32 +737,60 @@ impl RadrootsAppBackend for DesktopBackend { fn home_action_states(&self) -> Vec<HomeActionState> { #[cfg(target_os = "macos")] { - return vec![ - HomeActionState { - kind: HomeActionKind::BackupSecretKey, - label: "Back Up Secret Key".to_owned(), - enabled: true, - pending: false, - }, - HomeActionState { - kind: HomeActionKind::RevealRawSecretKey, - label: "Reveal Raw Secret Key".to_owned(), - enabled: true, - pending: false, - }, - HomeActionState { - kind: HomeActionKind::RemoveLocalKey, - label: "Remove Key From This Device".to_owned(), - enabled: true, - pending: false, - }, - HomeActionState { - kind: HomeActionKind::ResetDevice, - label: "Reset This Device".to_owned(), - enabled: true, - pending: false, - }, - ]; + let Ok(manager) = Self::accounts_manager() else { + return Vec::new(); + }; + let Ok(status) = manager + .selected_account_status() + .map_err(|source| source.to_string()) + else { + return Vec::new(); + }; + + return match status { + RadrootsNostrSelectedAccountStatus::NotConfigured => Vec::new(), + RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { + if matches!( + remote_signer::custody_for_account_id(account.account_id.as_str()), + Ok(RadrootsAccountCustody::RemoteSigner) + ) { + vec![HomeActionState { + kind: HomeActionKind::DisconnectSigner, + label: "Disconnect Remote Signer".to_owned(), + enabled: true, + pending: false, + }] + } else { + Vec::new() + } + } + RadrootsNostrSelectedAccountStatus::Ready { .. } => vec![ + HomeActionState { + kind: HomeActionKind::BackupSecretKey, + label: "Back Up Secret Key".to_owned(), + enabled: true, + pending: false, + }, + HomeActionState { + kind: HomeActionKind::RevealRawSecretKey, + label: "Reveal Raw Secret Key".to_owned(), + enabled: true, + pending: false, + }, + HomeActionState { + kind: HomeActionKind::RemoveLocalKey, + label: "Remove Key From This Device".to_owned(), + enabled: true, + pending: false, + }, + HomeActionState { + kind: HomeActionKind::ResetDevice, + label: "Reset This Device".to_owned(), + enabled: true, + pending: false, + }, + ], + }; } #[cfg(not(target_os = "macos"))] @@ -700,7 +816,10 @@ impl RadrootsAppBackend for DesktopBackend { Self::reset_local_device_state(&manager, accounts_path.as_path()) .map(HomeActionResult::IdentityState) } - HomeActionKind::DisconnectSigner => Ok(HomeActionResult::None), + HomeActionKind::DisconnectSigner => { + remote_signer::disconnect_selected_remote_signer(&manager) + .map(HomeActionResult::IdentityState) + } }; } @@ -725,6 +844,22 @@ impl RadrootsAppBackend for DesktopBackend { Ok(HomeActionResult::None) } } + + fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> { + #[cfg(target_os = "macos")] + { + return self + .remote_signer + .take_update() + .transpose() + .map(|state| state.flatten()); + } + + #[cfg(not(target_os = "macos"))] + { + Ok(None) + } + } } fn main() -> eframe::Result<()> { diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs @@ -0,0 +1,403 @@ +use super::DesktopBackend; +use radroots_app_apple_security::{APPLE_NOSTR_SERVICE, RadrootsAppleKeychainVault}; +use radroots_app_core::{ + IdentityGateState, RadrootsAccountCustody, RadrootsPendingRemoteSignerConnection, + 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, +}; +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)] +pub(crate) struct DesktopRemoteSigner { + update: Arc<Mutex<Option<Result<Option<IdentityGateState>, String>>>>, + changed: Arc<AtomicBool>, + connecting: Arc<AtomicBool>, + polling: Arc<AtomicBool>, +} + +impl DesktopRemoteSigner { + pub(crate) fn new() -> Self { + let tracker = Self::default(); + if let Err(error) = tracker.resume_pending() { + tracker.push_update(Err(error)); + } + 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()) + } + + pub(crate) fn is_connecting(&self) -> bool { + self.connecting.load(Ordering::Acquire) + } + + pub(crate) fn action_state(&self) -> Result<SetupActionState, String> { + if self.is_connecting() { + return Ok(SetupActionState { + label: "Connecting Remote Signer...".to_owned(), + enabled: false, + pending: true, + }); + } + + if pending_connection()?.is_some() { + return Ok(SetupActionState { + label: "Remote Signer Waiting for Approval".to_owned(), + enabled: false, + pending: false, + }); + } + + Ok(SetupActionState { + label: "Connect Remote Signer".to_owned(), + enabled: true, + pending: false, + }) + } + + 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::RetryableError { .. }) => { + 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::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); + } + } +} + +pub(crate) fn preview_connection(input: &str) -> Result<RadrootsRemoteSignerPreview, String> { + let preview = radroots_app_remote_signer_preview(input).map_err(|error| error.to_string())?; + Ok(RadrootsRemoteSignerPreview { + source_label: preview.source_label().to_owned(), + signer_npub: preview.signer_identity.public_key_npub, + relays: preview.relays, + requested_permissions: Vec::new(), + }) +} + +pub(crate) fn pending_connection() -> Result<Option<RadrootsPendingRemoteSignerConnection>, String> +{ + Ok( + 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> { + match status { + RadrootsNostrSelectedAccountStatus::NotConfigured => Ok(IdentityGateState::Missing), + RadrootsNostrSelectedAccountStatus::Ready { account } => Ok(IdentityGateState::Ready { + account_id: account.account_id.to_string(), + }), + RadrootsNostrSelectedAccountStatus::PublicOnly { account } => { + if active_session_for_account_id(account.account_id.as_str())?.is_some() { + Ok(IdentityGateState::Ready { + account_id: account.account_id.to_string(), + }) + } else { + Ok(IdentityGateState::Missing) + } + } + } +} + +pub(crate) fn custody_for_account_id(account_id: &str) -> Result<RadrootsAccountCustody, String> { + if active_session_for_account_id(account_id)?.is_some() { + Ok(RadrootsAccountCustody::RemoteSigner) + } else { + Ok(RadrootsAccountCustody::LocalManaged) + } +} + +pub(crate) fn disconnect_selected_remote_signer( + manager: &RadrootsNostrAccountsManager, +) -> Result<IdentityGateState, String> { + let Some(account_id) = manager + .selected_account_id() + .map_err(|source| source.to_string())? + else { + return Ok(IdentityGateState::Missing); + }; + let Some(session) = remove_active_session(account_id.as_str())? else { + return Ok(IdentityGateState::Missing); + }; + remove_client_secret(session.client_account_id())?; + manager + .remove_account(&account_id) + .map_err(|source| source.to_string())?; + let status = manager + .selected_account_status() + .map_err(|source| source.to_string())?; + identity_state_from_status(status) +} + +pub(crate) fn cancel_pending_connection() -> Result<(), String> { + if let Some(session) = remove_pending_session()? { + remove_client_secret(session.client_account_id())?; + } + Ok(()) +} + +fn activate_remote_session( + client_account_id: &str, + user_identity: radroots_identity::RadrootsIdentityPublic, +) -> Result<IdentityGateState, String> { + let manager = DesktopBackend::accounts_manager()?; + manager + .upsert_public_identity( + user_identity.clone(), + Some(REMOTE_SIGNER_LABEL.to_owned()), + true, + ) + .map_err(|source| source.to_string())?; + let store_path = sessions_path()?; + let mut state = load_sessions(store_path.as_path())?; + state + .activate_session(client_account_id, user_identity.clone()) + .ok_or_else(|| "pending remote signer session disappeared before activation".to_owned())?; + save_sessions(store_path.as_path(), &state)?; + Ok(IdentityGateState::Ready { + account_id: user_identity.id.to_string(), + }) +} + +fn pending_session_record() +-> Result<Option<radroots_app_remote_signer::RadrootsAppRemoteSignerSessionRecord>, String> { + let store_path = sessions_path()?; + let state = load_sessions(store_path.as_path())?; + Ok(state.pending_session().cloned()) +} + +fn active_session_for_account_id( + account_id: &str, +) -> Result<Option<radroots_app_remote_signer::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> { + let store_path = sessions_path()?; + let mut state = load_sessions(store_path.as_path())?; + let removed = state.remove_pending_session(); + save_sessions(store_path.as_path(), &state)?; + Ok(removed) +} + +fn remove_active_session( + account_id: &str, +) -> Result<Option<radroots_app_remote_signer::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); + save_sessions(store_path.as_path(), &state)?; + Ok(removed) +} + +fn load_sessions(path: &Path) -> Result<RadrootsAppRemoteSignerSessionStoreState, String> { + RadrootsAppRemoteSignerSessionStoreState::load(path).map_err(|error| error.to_string()) +} + +fn save_sessions( + path: &Path, + state: &RadrootsAppRemoteSignerSessionStoreState, +) -> Result<(), String> { + state.save(path).map_err(|error| error.to_string()) +} + +fn sessions_path() -> Result<PathBuf, String> { + Ok(DesktopBackend::app_data_root()? + .join("nostr") + .join("remote-signer-sessions.json")) +} + +fn client_secret_vault() -> RadrootsAppleKeychainVault { + RadrootsAppleKeychainVault::new(APPLE_NOSTR_SERVICE) +} + +fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { + let account_id = radroots_identity::RadrootsIdentityId::try_from(client_account_id) + .map_err(|_| "invalid remote signer client account id".to_owned())?; + client_secret_vault() + .store_secret_hex(&account_id, secret_key_hex) + .map_err(|source| source.to_string()) +} + +fn load_client_secret(client_account_id: &str) -> Result<String, String> { + let account_id = radroots_identity::RadrootsIdentityId::try_from(client_account_id) + .map_err(|_| "invalid remote signer client account id".to_owned())?; + client_secret_vault() + .load_secret_hex(&account_id) + .map_err(|source| source.to_string())? + .ok_or_else(|| "remote signer session secret is missing".to_owned()) +} + +fn remove_client_secret(client_account_id: &str) -> Result<(), String> { + let account_id = radroots_identity::RadrootsIdentityId::try_from(client_account_id) + .map_err(|_| "invalid remote signer client account id".to_owned())?; + client_secret_vault() + .remove_secret(&account_id) + .map_err(|source| source.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_app_test_support::FIXTURE_BOB; + + #[test] + fn preview_connection_maps_signer_details() { + let preview = preview_connection( + "http://localhost/connect?uri=bunker%3A%2F%2Fnpub1uqnxu08mp55gd7guw06ls68nhxp8xuf7tlxe0sypvcl42x9ykwhsd55k2g%3Frelay%3Dws%253A%252F%252Flocalhost%253A8080", + ) + .expect("preview"); + + assert_eq!(preview.source_label, "discovery url"); + assert_eq!(preview.signer_npub, FIXTURE_BOB.npub); + assert_eq!(preview.relays, vec!["ws://localhost:8080".to_owned()]); + } +}