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:
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,
+ })
}
}