app

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

commit ce88bb96fd54ce45893521de1ee5defaba2a33e5
parent ddf078e960e8942d07a5205dd87747a8b18a2128
Author: triesap <tyson@radroots.org>
Date:   Sat, 28 Mar 2026 18:10:12 +0000

app: share remote signer teardown and recovery

Diffstat:
Mcrates/android/src/remote_signer.rs | 70++++++++++++++++++++++++++++------------------------------------------
Mcrates/desktop/src/remote_signer.rs | 70++++++++++++++++++++++++++++------------------------------------------
Mcrates/ios/src/remote_signer.rs | 70++++++++++++++++++++++++++++------------------------------------------
Mcrates/remote-signer/Cargo.toml | 1+
Mcrates/remote-signer/src/controller.rs | 26+++++++++++++++++++++++---
Acrates/remote-signer/src/custody.rs | 353+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/remote-signer/src/lib.rs | 8+++++++-
Mcrates/remote-signer/src/session.rs | 44++++++++++++++++++++++++++++++++++++++------
8 files changed, 506 insertions(+), 136 deletions(-)

diff --git a/crates/android/src/remote_signer.rs b/crates/android/src/remote_signer.rs @@ -7,7 +7,9 @@ use radroots_app_core::{ use radroots_app_remote_signer::{ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, - RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview, + RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session, + radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview, + radroots_app_remote_signer_reconcile_startup, }; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{ @@ -23,6 +25,18 @@ struct AndroidRemoteSignerHooks; impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks { type ReadyState = IdentityGateState; + fn reconcile_startup_state(&self) -> Result<(), String> { + let manager = crate::storage::accounts_manager()?; + let store_path = sessions_path()?; + radroots_app_remote_signer_reconcile_startup( + &manager, + store_path.as_path(), + REMOTE_SIGNER_LABEL, + load_client_secret, + remove_client_secret, + ) + } + fn store_pending_session( &self, pending: &RadrootsAppRemoteSignerPendingSession, @@ -66,12 +80,8 @@ impl RadrootsAppRemoteSignerControllerHooks for AndroidRemoteSignerHooks { 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) - } + let store_path = sessions_path()?; + radroots_app_remote_signer_clear_pending_session(store_path.as_path(), remove_client_secret) } } @@ -175,27 +185,21 @@ pub(crate) fn custody_for_account_id(account_id: &str) -> Result<RadrootsAccount 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())?; + let store_path = sessions_path()?; + let status = radroots_app_remote_signer_disconnect_selected( + manager, + store_path.as_path(), + remove_client_secret, + )?; identity_state_from_status(status) } pub(crate) fn cancel_pending_connection() -> Result<(), String> { - let _ = AndroidRemoteSignerHooks.clear_pending_session()?; + let store_path = sessions_path()?; + let _ = radroots_app_remote_signer_clear_pending_session( + store_path.as_path(), + remove_client_secret, + )?; Ok(()) } @@ -236,24 +240,6 @@ fn active_session_for_account_id( Ok(state.active_session_for_account_id(account_id).cloned()) } -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(); - save_sessions(store_path.as_path(), &state)?; - Ok(removed) -} - -fn remove_active_session( - account_id: &str, -) -> 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); - 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()) } diff --git a/crates/desktop/src/remote_signer.rs b/crates/desktop/src/remote_signer.rs @@ -7,7 +7,9 @@ use radroots_app_core::{ use radroots_app_remote_signer::{ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, - RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview, + RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session, + radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview, + radroots_app_remote_signer_reconcile_startup, }; use radroots_nostr_accounts::prelude::{ RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSelectedAccountStatus, @@ -22,6 +24,18 @@ struct DesktopRemoteSignerHooks; impl RadrootsAppRemoteSignerControllerHooks for DesktopRemoteSignerHooks { type ReadyState = IdentityGateState; + fn reconcile_startup_state(&self) -> Result<(), String> { + let manager = DesktopBackend::accounts_manager()?; + let store_path = sessions_path()?; + radroots_app_remote_signer_reconcile_startup( + &manager, + store_path.as_path(), + REMOTE_SIGNER_LABEL, + load_client_secret, + remove_client_secret, + ) + } + fn store_pending_session( &self, pending: &RadrootsAppRemoteSignerPendingSession, @@ -65,12 +79,8 @@ impl RadrootsAppRemoteSignerControllerHooks for DesktopRemoteSignerHooks { 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) - } + let store_path = sessions_path()?; + radroots_app_remote_signer_clear_pending_session(store_path.as_path(), remove_client_secret) } } @@ -174,27 +184,21 @@ pub(crate) fn custody_for_account_id(account_id: &str) -> Result<RadrootsAccount 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())?; + let store_path = sessions_path()?; + let status = radroots_app_remote_signer_disconnect_selected( + manager, + store_path.as_path(), + remove_client_secret, + )?; identity_state_from_status(status) } pub(crate) fn cancel_pending_connection() -> Result<(), String> { - let _ = DesktopRemoteSignerHooks.clear_pending_session()?; + let store_path = sessions_path()?; + let _ = radroots_app_remote_signer_clear_pending_session( + store_path.as_path(), + remove_client_secret, + )?; Ok(()) } @@ -235,24 +239,6 @@ fn active_session_for_account_id( Ok(state.active_session_for_account_id(account_id).cloned()) } -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(); - save_sessions(store_path.as_path(), &state)?; - Ok(removed) -} - -fn remove_active_session( - account_id: &str, -) -> 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); - 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()) } diff --git a/crates/ios/src/remote_signer.rs b/crates/ios/src/remote_signer.rs @@ -7,7 +7,9 @@ use radroots_app_core::{ use radroots_app_remote_signer::{ RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks, RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, - RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_preview, + RadrootsAppRemoteSignerSessionStoreState, radroots_app_remote_signer_clear_pending_session, + radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_preview, + radroots_app_remote_signer_reconcile_startup, }; use radroots_identity::RadrootsIdentityId; use radroots_nostr_accounts::prelude::{ @@ -23,6 +25,18 @@ struct IosRemoteSignerHooks; impl RadrootsAppRemoteSignerControllerHooks for IosRemoteSignerHooks { type ReadyState = IdentityGateState; + fn reconcile_startup_state(&self) -> Result<(), String> { + let manager = storage::accounts_manager()?; + let store_path = sessions_path()?; + radroots_app_remote_signer_reconcile_startup( + &manager, + store_path.as_path(), + REMOTE_SIGNER_LABEL, + load_client_secret, + remove_client_secret, + ) + } + fn store_pending_session( &self, pending: &RadrootsAppRemoteSignerPendingSession, @@ -66,12 +80,8 @@ impl RadrootsAppRemoteSignerControllerHooks for IosRemoteSignerHooks { 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) - } + let store_path = sessions_path()?; + radroots_app_remote_signer_clear_pending_session(store_path.as_path(), remove_client_secret) } } @@ -175,27 +185,21 @@ pub(crate) fn custody_for_account_id(account_id: &str) -> Result<RadrootsAccount 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())?; + let store_path = sessions_path()?; + let status = radroots_app_remote_signer_disconnect_selected( + manager, + store_path.as_path(), + remove_client_secret, + )?; identity_state_from_status(status) } pub(crate) fn cancel_pending_connection() -> Result<(), String> { - let _ = IosRemoteSignerHooks.clear_pending_session()?; + let store_path = sessions_path()?; + let _ = radroots_app_remote_signer_clear_pending_session( + store_path.as_path(), + remove_client_secret, + )?; Ok(()) } @@ -236,24 +240,6 @@ fn active_session_for_account_id( Ok(state.active_session_for_account_id(account_id).cloned()) } -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(); - save_sessions(store_path.as_path(), &state)?; - Ok(removed) -} - -fn remove_active_session( - account_id: &str, -) -> 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); - 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()) } diff --git a/crates/remote-signer/Cargo.toml b/crates/remote-signer/Cargo.toml @@ -16,6 +16,7 @@ workspace = true [dependencies] nostr = { workspace = true, features = ["nip44"] } radroots-identity.workspace = true +radroots-nostr-accounts.workspace = true radroots-nostr.workspace = true radroots-nostr-connect.workspace = true serde.workspace = true diff --git a/crates/remote-signer/src/controller.rs b/crates/remote-signer/src/controller.rs @@ -11,6 +11,10 @@ use std::time::Duration; pub trait RadrootsAppRemoteSignerControllerHooks: Clone + Send + Sync + 'static { type ReadyState: Send + 'static; + fn reconcile_startup_state(&self) -> Result<(), String> { + Ok(()) + } + fn store_pending_session( &self, pending: &RadrootsAppRemoteSignerPendingSession, @@ -73,7 +77,9 @@ where polling: Arc::new(AtomicBool::new(false)), _ready_state: PhantomData, }; - if let Err(error) = controller.resume_pending() { + if let Err(error) = controller.reconcile_startup_state() { + controller.push_update(Err(error)); + } else if let Err(error) = controller.resume_pending() { controller.push_update(Err(error)); } controller @@ -133,6 +139,10 @@ where self.hooks.pending_session_record() } + fn reconcile_startup_state(&self) -> Result<(), String> { + self.hooks.reconcile_startup_state() + } + fn resume_pending(&self) -> Result<(), String> { let Some(record) = self.pending_session_record()? else { return Ok(()); @@ -193,8 +203,18 @@ where } Ok(RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }) | Ok(RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }) => { - let _ = tracker.hooks.clear_pending_session(); - tracker.push_update(Err(message)); + let outcome = tracker + .hooks + .clear_pending_session() + .map(|_| None) + .unwrap_or_else(|cleanup_error| Some(cleanup_error)); + let error = match outcome { + Some(cleanup_error) => format!( + "{message}. remote signer cleanup needs retry: {cleanup_error}" + ), + None => message, + }; + tracker.push_update(Err(error)); tracker.polling.store(false, Ordering::Release); return; } diff --git a/crates/remote-signer/src/custody.rs b/crates/remote-signer/src/custody.rs @@ -0,0 +1,353 @@ +use crate::session::{ + RadrootsAppRemoteSignerSessionRecord, RadrootsAppRemoteSignerSessionStatus, + RadrootsAppRemoteSignerSessionStoreLoadResult, RadrootsAppRemoteSignerSessionStoreState, +}; +use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountRecord, RadrootsNostrAccountsManager, RadrootsNostrSelectedAccountStatus, +}; +use std::collections::HashSet; +use std::path::Path; + +pub fn radroots_app_remote_signer_clear_pending_session( + path: &Path, + remove_client_secret: impl Fn(&str) -> Result<(), String>, +) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, String> { + let mut state = load_sessions(path)?; + let Some(record) = state.pending_session().cloned() else { + return Ok(None); + }; + remove_client_secret(record.client_account_id())?; + let removed = state.remove_pending_session(); + save_sessions(path, &state)?; + Ok(removed) +} + +pub fn radroots_app_remote_signer_disconnect_selected( + manager: &RadrootsNostrAccountsManager, + path: &Path, + remove_client_secret: impl Fn(&str) -> Result<(), String>, +) -> Result<RadrootsNostrSelectedAccountStatus, String> { + let Some(account_id) = manager + .selected_account_id() + .map_err(|source| source.to_string())? + else { + return Ok(RadrootsNostrSelectedAccountStatus::NotConfigured); + }; + + let state = load_sessions(path)?; + let Some(session) = state + .active_session_for_account_id(account_id.as_str()) + .cloned() + else { + return Ok(RadrootsNostrSelectedAccountStatus::NotConfigured); + }; + + manager + .remove_account(&account_id) + .map_err(|source| source.to_string())?; + + if let Err(error) = remove_client_secret(session.client_account_id()) { + return Err(format!( + "remote signer account was removed but session secret cleanup failed: {error}" + )); + } + + let mut state = load_sessions(path)?; + let removed = state.remove_active_session_for_account_id(account_id.as_str()); + if removed.is_none() { + return Err( + "remote signer account was removed but session record cleanup could not complete" + .to_owned(), + ); + } + save_sessions(path, &state)?; + manager + .selected_account_status() + .map_err(|source| source.to_string()) +} + +pub fn radroots_app_remote_signer_reconcile_startup( + manager: &RadrootsNostrAccountsManager, + path: &Path, + remote_signer_label: &str, + load_client_secret: impl Fn(&str) -> Result<String, String>, + remove_client_secret: impl Fn(&str) -> Result<(), String>, +) -> Result<(), String> { + let load = load_sessions_with_recovery(path)?; + let mut state = load.state; + let mut dirty = false; + let accounts = manager + .list_accounts() + .map_err(|source| source.to_string())?; + let account_ids = accounts + .iter() + .map(|record| record.account_id.to_string()) + .collect::<HashSet<_>>(); + + if load.recovered_from_corruption { + for account in remote_signer_public_only_accounts(manager, &accounts, remote_signer_label)? + { + manager + .remove_account(&account.account_id) + .map_err(|source| source.to_string())?; + } + } + + if let Some(record) = state.pending_session().cloned() { + if load_client_secret(record.client_account_id()).is_err() { + state.remove_pending_session(); + dirty = true; + } + } + + let stale_active_sessions = state + .sessions + .iter() + .filter(|record| record.status == RadrootsAppRemoteSignerSessionStatus::Active) + .filter_map(|record| { + let account_id = record.account_id()?; + (!account_ids.contains(account_id)).then_some(record.clone()) + }) + .collect::<Vec<_>>(); + + for session in stale_active_sessions { + remove_client_secret(session.client_account_id())?; + let Some(account_id) = session.account_id() else { + continue; + }; + state.remove_active_session_for_account_id(account_id); + dirty = true; + } + + if dirty || load.recovered_from_corruption { + save_sessions(path, &state)?; + } + + Ok(()) +} + +fn remote_signer_public_only_accounts( + manager: &RadrootsNostrAccountsManager, + accounts: &[RadrootsNostrAccountRecord], + remote_signer_label: &str, +) -> Result<Vec<RadrootsNostrAccountRecord>, String> { + let mut stale = Vec::new(); + for account in accounts { + if account.label.as_deref() != Some(remote_signer_label) { + continue; + } + if manager + .get_signing_identity(&account.account_id) + .map_err(|source| source.to_string())? + .is_none() + { + stale.push(account.clone()); + } + } + Ok(stale) +} + +fn load_sessions(path: &Path) -> Result<RadrootsAppRemoteSignerSessionStoreState, String> { + RadrootsAppRemoteSignerSessionStoreState::load(path).map_err(|error| error.to_string()) +} + +fn load_sessions_with_recovery( + path: &Path, +) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, String> { + RadrootsAppRemoteSignerSessionStoreState::load_with_recovery(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()) +} + +#[cfg(test)] +mod tests { + use super::*; + use radroots_app_test_support::{FIXTURE_ALICE, FIXTURE_BOB, FIXTURE_CAROL, fixture_identity}; + use radroots_identity::RadrootsIdentityId; + use radroots_nostr_accounts::prelude::{ + RadrootsNostrAccountsManager, RadrootsNostrSecretVault, RadrootsNostrSecretVaultMemory, + RadrootsNostrSelectedAccountStatus, + }; + + const REMOTE_SIGNER_LABEL: &str = "remote signer"; + + fn fixture_public( + label: &radroots_app_test_support::RadrootsAppApprovedFixtureIdentity, + ) -> radroots_identity::RadrootsIdentityPublic { + fixture_identity(label).expect("identity").to_public() + } + + fn fixture_account_id(value: &str) -> RadrootsIdentityId { + RadrootsIdentityId::try_from(value).expect("account id") + } + + fn secret_store_secret( + vault: &RadrootsNostrSecretVaultMemory, + client_account_id: &str, + secret: &str, + ) { + vault + .store_secret_hex(&fixture_account_id(client_account_id), secret) + .expect("store secret"); + } + + fn secret_loader( + vault: RadrootsNostrSecretVaultMemory, + ) -> impl Fn(&str) -> Result<String, String> { + move |client_account_id| { + vault + .load_secret_hex(&fixture_account_id(client_account_id)) + .map_err(|source| source.to_string())? + .ok_or_else(|| "missing secret".to_owned()) + } + } + + fn secret_remover( + vault: RadrootsNostrSecretVaultMemory, + ) -> impl Fn(&str) -> Result<(), String> { + move |client_account_id| { + vault + .remove_secret(&fixture_account_id(client_account_id)) + .map_err(|source| source.to_string()) + } + } + + fn write_pending_state(path: &Path) -> RadrootsAppRemoteSignerSessionRecord { + let record = RadrootsAppRemoteSignerSessionRecord::pending( + fixture_public(&FIXTURE_ALICE), + fixture_public(&FIXTURE_BOB), + vec!["wss://relay.example.com".to_owned()], + ); + let mut state = RadrootsAppRemoteSignerSessionStoreState::default(); + state.upsert_pending(record.clone()).expect("pending"); + state.save(path).expect("save"); + record + } + + fn write_active_state(path: &Path) -> RadrootsAppRemoteSignerSessionRecord { + let user_identity = fixture_public(&FIXTURE_CAROL); + let mut record = RadrootsAppRemoteSignerSessionRecord::pending( + fixture_public(&FIXTURE_ALICE), + fixture_public(&FIXTURE_BOB), + vec!["wss://relay.example.com".to_owned()], + ); + record.user_identity = Some(user_identity); + record.status = RadrootsAppRemoteSignerSessionStatus::Active; + let mut state = RadrootsAppRemoteSignerSessionStoreState::default(); + state.sessions.push(record.clone()); + state.save(path).expect("save"); + record + } + + #[test] + fn clear_pending_session_removes_secret_and_session_record() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sessions.json"); + let record = write_pending_state(path.as_path()); + let vault = RadrootsNostrSecretVaultMemory::new(); + secret_store_secret(&vault, record.client_account_id(), "deadbeef"); + + let removed = radroots_app_remote_signer_clear_pending_session( + path.as_path(), + secret_remover(vault.clone()), + ) + .expect("clear pending"); + + assert_eq!( + removed.expect("removed").client_account_id(), + record.client_account_id() + ); + assert!( + vault + .load_secret_hex(&fixture_account_id(record.client_account_id())) + .expect("load") + .is_none() + ); + assert!( + RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()) + .expect("load") + .sessions + .is_empty() + ); + } + + #[test] + fn disconnect_selected_remote_signer_leaves_session_for_retry_when_secret_cleanup_fails() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sessions.json"); + let record = write_active_state(path.as_path()); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + manager + .upsert_public_identity( + record.user_identity.clone().expect("user"), + Some(REMOTE_SIGNER_LABEL.to_owned()), + true, + ) + .expect("upsert"); + + let error = radroots_app_remote_signer_disconnect_selected( + &manager, + path.as_path(), + |_client_account_id| Err("vault unavailable".to_owned()), + ) + .expect_err("cleanup failure"); + + assert!(error.contains("session secret cleanup failed")); + assert!(matches!( + manager.selected_account_status().expect("status"), + RadrootsNostrSelectedAccountStatus::NotConfigured + )); + assert!( + RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()) + .expect("load") + .active_session_for_account_id( + record + .account_id() + .expect("account id after disconnect failure") + ) + .is_some() + ); + } + + #[test] + fn reconcile_startup_removes_remote_signer_public_only_accounts_after_store_quarantine() { + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().join("sessions.json"); + std::fs::write(path.as_path(), "{invalid").expect("write invalid"); + let manager = RadrootsNostrAccountsManager::new_in_memory(); + let public = fixture_public(&FIXTURE_CAROL); + let account_id = public.id.clone(); + manager + .upsert_public_identity(public, Some(REMOTE_SIGNER_LABEL.to_owned()), true) + .expect("upsert"); + + radroots_app_remote_signer_reconcile_startup( + &manager, + path.as_path(), + REMOTE_SIGNER_LABEL, + secret_loader(RadrootsNostrSecretVaultMemory::new()), + secret_remover(RadrootsNostrSecretVaultMemory::new()), + ) + .expect("reconcile"); + + assert!( + manager + .list_accounts() + .expect("accounts") + .iter() + .all(|record| record.account_id != account_id) + ); + assert!( + RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()) + .expect("load") + .sessions + .is_empty() + ); + } +} diff --git a/crates/remote-signer/src/lib.rs b/crates/remote-signer/src/lib.rs @@ -1,12 +1,17 @@ #![forbid(unsafe_code)] mod controller; +mod custody; mod error; mod input; mod protocol; mod session; pub use controller::{RadrootsAppRemoteSignerController, RadrootsAppRemoteSignerControllerHooks}; +pub use custody::{ + radroots_app_remote_signer_clear_pending_session, + radroots_app_remote_signer_disconnect_selected, radroots_app_remote_signer_reconcile_startup, +}; pub use error::RadrootsAppRemoteSignerError; pub use input::{ RadrootsAppRemoteSignerSource, RadrootsAppRemoteSignerTarget, @@ -18,5 +23,6 @@ pub use protocol::{ }; pub use session::{ RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION, RadrootsAppRemoteSignerSessionRecord, - RadrootsAppRemoteSignerSessionStatus, RadrootsAppRemoteSignerSessionStoreState, + RadrootsAppRemoteSignerSessionStatus, RadrootsAppRemoteSignerSessionStoreLoadResult, + RadrootsAppRemoteSignerSessionStoreState, }; diff --git a/crates/remote-signer/src/session.rs b/crates/remote-signer/src/session.rs @@ -31,6 +31,12 @@ pub struct RadrootsAppRemoteSignerSessionStoreState { pub sessions: Vec<RadrootsAppRemoteSignerSessionRecord>, } +#[derive(Debug, Clone)] +pub struct RadrootsAppRemoteSignerSessionStoreLoadResult { + pub state: RadrootsAppRemoteSignerSessionStoreState, + pub recovered_from_corruption: bool, +} + impl Default for RadrootsAppRemoteSignerSessionStoreState { fn default() -> Self { Self { @@ -71,9 +77,20 @@ impl RadrootsAppRemoteSignerSessionRecord { impl RadrootsAppRemoteSignerSessionStoreState { pub fn load(path: &Path) -> Result<Self, RadrootsAppRemoteSignerError> { + Ok(Self::load_with_recovery(path)?.state) + } + + pub fn load_with_recovery( + path: &Path, + ) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, RadrootsAppRemoteSignerError> { 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) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { + state: Self::default(), + recovered_from_corruption: false, + }) + } Err(error) => Err(RadrootsAppRemoteSignerError::SessionStoreIo( error.to_string(), )), @@ -181,7 +198,10 @@ impl RadrootsAppRemoteSignerSessionStoreState { Some(self.sessions.remove(index)) } - fn load_bytes(path: &Path, contents: Vec<u8>) -> Result<Self, RadrootsAppRemoteSignerError> { + fn load_bytes( + path: &Path, + contents: Vec<u8>, + ) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, RadrootsAppRemoteSignerError> { let contents = String::from_utf8(contents).map_err(|error| { RadrootsAppRemoteSignerError::InvalidSessionStore(format!( "session store was not valid utf-8: {error}" @@ -193,7 +213,10 @@ impl RadrootsAppRemoteSignerSessionStoreState { Err(error) => { quarantine_invalid_store(path)?; let _ = error; - return Ok(Self::default()); + return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { + state: Self::default(), + recovered_from_corruption: true, + }); } }; @@ -205,16 +228,25 @@ impl RadrootsAppRemoteSignerSessionStoreState { Err(error) => { quarantine_invalid_store(path)?; let _ = error; - return Ok(Self::default()); + return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { + state: Self::default(), + recovered_from_corruption: true, + }); } }; if state.version != RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION { quarantine_invalid_store(path)?; - return Ok(Self::default()); + return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { + state: Self::default(), + recovered_from_corruption: true, + }); } - Ok(state) + Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { + state, + recovered_from_corruption: false, + }) } }