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