app

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

commit 71ce53ad12dfe62e61e14ef26d801049aee01a9b
parent 3409b2a838b64fa1e720ecd3229f9d9ccd25df16
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 21:31:39 +0000

android: add remote signer backend

- add an android remote signer session layer that previews bunker urls, persists pending sessions, and stores the client transport secret in the android keystore
- 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:
Mcrates/android/Cargo.toml | 1+
Mcrates/android/src/lib.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Acrates/android/src/remote_signer.rs | 381+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 551 insertions(+), 29 deletions(-)

diff --git a/crates/android/Cargo.toml b/crates/android/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["cdylib", "rlib"] eframe = { workspace = true, features = ["android-game-activity", "glow"] } log.workspace = true radroots-app-core = { path = "../core" } +radroots-app-remote-signer = { path = "../remote-signer" } radroots-geocoder.workspace = true radroots-identity.workspace = true radroots-nostr-accounts.workspace = true diff --git a/crates/android/src/lib.rs b/crates/android/src/lib.rs @@ -36,6 +36,8 @@ use zeroize::Zeroizing; mod country_lookup; #[cfg(any(target_os = "android", test))] mod offline_geocoder; +#[cfg(target_os = "android")] +mod remote_signer; #[cfg(any(target_os = "android", test))] mod reverse_lookup; #[cfg(any(target_os = "android", test))] @@ -50,6 +52,8 @@ mod vault; struct AndroidBackend { country_lookup: country_lookup::AndroidCountryLookup, offline_geocoder: offline_geocoder::AndroidOfflineGeocoder, + #[cfg(target_os = "android")] + remote_signer: remote_signer::AndroidRemoteSigner, reverse_lookup: reverse_lookup::AndroidReverseLookup, } @@ -70,7 +74,10 @@ impl RadrootsAppBackend for AndroidBackend { #[cfg(target_os = "android")] { let manager = Self::accounts_manager()?; - return Self::identity_state_from_manager(&manager); + let status = manager + .selected_account_status() + .map_err(|source| source.to_string())?; + return remote_signer::identity_state_from_status(status); } #[cfg(not(target_os = "android"))] @@ -313,36 +320,143 @@ impl RadrootsAppBackend for AndroidBackend { } } + fn remote_signer_action_state(&self) -> Option<SetupActionState> { + #[cfg(target_os = "android")] + { + 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 = "android"))] + { + None + } + } + + fn preview_remote_signer_connection( + &self, + input: &str, + ) -> Result<radroots_app_core::RadrootsRemoteSignerPreview, String> { + #[cfg(target_os = "android")] + { + return remote_signer::preview_connection(input); + } + + #[cfg(not(target_os = "android"))] + { + 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 = "android")] + { + self.remote_signer.begin_connect(input)?; + return Ok(None); + } + + #[cfg(not(target_os = "android"))] + { + let _ = input; + Ok(None) + } + } + + fn pending_remote_signer_connection( + &self, + ) -> Result<Option<radroots_app_core::RadrootsPendingRemoteSignerConnection>, String> { + #[cfg(target_os = "android")] + { + return remote_signer::pending_connection(); + } + + #[cfg(not(target_os = "android"))] + { + Ok(None) + } + } + + fn request_cancel_pending_remote_signer_connection(&self) -> Result<(), String> { + #[cfg(target_os = "android")] + { + return remote_signer::cancel_pending_connection(); + } + + #[cfg(not(target_os = "android"))] + { + Ok(()) + } + } + fn home_action_states(&self) -> Vec<HomeActionState> { #[cfg(target_os = "android")] { let secret_key_export_pending = Self::secret_key_export_pending(); - return vec![ - HomeActionState { - kind: HomeActionKind::BackupSecretKey, - label: "Back Up Secret Key".to_owned(), - enabled: !secret_key_export_pending, - pending: secret_key_export_pending, - }, - HomeActionState { - kind: HomeActionKind::RevealRawSecretKey, - label: "Reveal Raw Secret Key".to_owned(), - enabled: !secret_key_export_pending, - pending: secret_key_export_pending, - }, - 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: !secret_key_export_pending, + pending: secret_key_export_pending, + }, + HomeActionState { + kind: HomeActionKind::RevealRawSecretKey, + label: "Reveal Raw Secret Key".to_owned(), + enabled: !secret_key_export_pending, + pending: secret_key_export_pending, + }, + 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 = "android"))] @@ -370,7 +484,11 @@ impl RadrootsAppBackend for AndroidBackend { Self::reset_local_device_state(&manager, accounts_path.as_path()) .map(HomeActionResult::IdentityState) } - HomeActionKind::DisconnectSigner => Ok(HomeActionResult::None), + HomeActionKind::DisconnectSigner => { + let manager = Self::accounts_manager()?; + remote_signer::disconnect_selected_remote_signer(&manager) + .map(HomeActionResult::IdentityState) + } }; } @@ -406,6 +524,22 @@ impl RadrootsAppBackend for AndroidBackend { Ok(None) } } + + fn poll_identity_state(&self) -> Result<Option<IdentityGateState>, String> { + #[cfg(target_os = "android")] + { + return self + .remote_signer + .take_update() + .transpose() + .map(|state| state.flatten()); + } + + #[cfg(not(target_os = "android"))] + { + Ok(None) + } + } } #[cfg(any(target_os = "android", test))] @@ -427,6 +561,8 @@ impl AndroidBackend { Self { country_lookup: country_lookup::AndroidCountryLookup::new(), offline_geocoder, + #[cfg(target_os = "android")] + remote_signer: remote_signer::AndroidRemoteSigner::new(), reverse_lookup: reverse_lookup::AndroidReverseLookup::new(), } } @@ -490,11 +626,15 @@ impl AndroidBackend { .map_err(|source| source.to_string())? .into_iter() .map(|record| { + #[cfg(target_os = "android")] + let custody = remote_signer::custody_for_account_id(record.account_id.as_str())?; + #[cfg(not(target_os = "android"))] + let custody = RadrootsAccountCustody::LocalManaged; Ok(RadrootsAccountSummary { account_id: record.account_id.to_string(), npub: record.public_identity.public_key_npub, label: record.label, - custody: RadrootsAccountCustody::LocalManaged, + custody, }) }) .collect() diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -0,0 +1,381 @@ +use crate::security::{ANDROID_NOSTR_SERVICE, resolve_nostr_storage_root}; +use crate::vault::RadrootsAndroidKeystoreVault; +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_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)] +pub(crate) struct AndroidRemoteSigner { + update: Arc<Mutex<Option<Result<Option<IdentityGateState>, String>>>>, + changed: Arc<AtomicBool>, + connecting: Arc<AtomicBool>, + polling: Arc<AtomicBool>, +} + +impl AndroidRemoteSigner { + 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(_)) | 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 = crate::storage::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> { + let root = resolve_nostr_storage_root().map_err(|source| source.to_string())?; + Ok(root.join("remote-signer-sessions.json")) +} + +fn client_secret_vault() -> RadrootsAndroidKeystoreVault { + RadrootsAndroidKeystoreVault::new(ANDROID_NOSTR_SERVICE) +} + +fn store_client_secret(client_account_id: &str, secret_key_hex: &str) -> Result<(), String> { + let account_id = 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 = 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 = 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()) +}