app

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

remote_signer.rs (20150B)


      1 use std::collections::HashSet;
      2 use std::fs;
      3 use std::path::{Path, PathBuf};
      4 
      5 use radroots_app_core::AppDesktopRuntimePaths;
      6 use radroots_app_remote_signer::{
      7     RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerError,
      8     RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord,
      9     RadrootsAppRemoteSignerSessionStatus, RadrootsAppRemoteSignerSessionStoreLoadResult,
     10     RadrootsAppRemoteSignerSessionStoreState,
     11 };
     12 use radroots_app_view::{AccountCustody, AppIdentityProjection};
     13 use radroots_identity::{IdentityError, RadrootsIdentityId};
     14 use radroots_nostr_accounts::prelude::{
     15     RadrootsNostrAccountRecord, RadrootsNostrAccountsError, RadrootsNostrAccountsManager,
     16     account_secret_slot,
     17 };
     18 use radroots_protected_store::RadrootsProtectedFileSecretVault;
     19 use radroots_secret_vault::{RadrootsSecretVault, RadrootsSecretVaultAccessError};
     20 use thiserror::Error;
     21 
     22 const REMOTE_SIGNER_LABEL: &str = "remote signer";
     23 const REMOTE_SIGNER_SESSIONS_FILE_NAME: &str = "remote-signer-sessions.json";
     24 const REMOTE_SIGNER_SESSIONS_DIR_NAME: &str = "nostr";
     25 const REMOTE_SIGNER_CLIENT_SECRET_DIR_NAME: &str = "remote_signer";
     26 
     27 #[derive(Clone, Debug, Eq, PartialEq)]
     28 pub(crate) struct DesktopRemoteSignerPaths {
     29     pub(crate) sessions_path: PathBuf,
     30     pub(crate) client_secret_root: PathBuf,
     31 }
     32 
     33 impl DesktopRemoteSignerPaths {
     34     pub(crate) fn from_runtime_paths(paths: &AppDesktopRuntimePaths) -> Self {
     35         Self {
     36             sessions_path: paths
     37                 .app
     38                 .data
     39                 .join(REMOTE_SIGNER_SESSIONS_DIR_NAME)
     40                 .join(REMOTE_SIGNER_SESSIONS_FILE_NAME),
     41             client_secret_root: paths
     42                 .shared_accounts
     43                 .secrets_root
     44                 .join(REMOTE_SIGNER_CLIENT_SECRET_DIR_NAME),
     45         }
     46     }
     47 }
     48 
     49 #[derive(Debug, Error)]
     50 pub(crate) enum DesktopRemoteSignerError {
     51     #[error(transparent)]
     52     Accounts(#[from] RadrootsNostrAccountsError),
     53     #[error(transparent)]
     54     Identity(#[from] IdentityError),
     55     #[error(transparent)]
     56     SessionStore(#[from] RadrootsAppRemoteSignerError),
     57     #[error(transparent)]
     58     SecretVault(#[from] RadrootsSecretVaultAccessError),
     59     #[error("{0}")]
     60     State(String),
     61 }
     62 
     63 pub(crate) fn reconcile_startup(
     64     manager: &RadrootsNostrAccountsManager,
     65     paths: &DesktopRemoteSignerPaths,
     66 ) -> Result<(), DesktopRemoteSignerError> {
     67     let load = load_sessions_with_recovery(paths)?;
     68     let mut state = load.state;
     69     let mut dirty = false;
     70     let accounts = manager.list_accounts()?;
     71     let account_ids = accounts
     72         .iter()
     73         .map(|record| record.account_id.to_string())
     74         .collect::<HashSet<_>>();
     75     let active_session_account_ids = state
     76         .sessions
     77         .iter()
     78         .filter(|record| record.status == RadrootsAppRemoteSignerSessionStatus::Active)
     79         .filter_map(|record| record.account_id().map(ToOwned::to_owned))
     80         .collect::<HashSet<_>>();
     81 
     82     if load.recovered_from_corruption || state.sessions.is_empty() {
     83         purge_client_secret_namespace(paths)?;
     84     }
     85 
     86     for account in remote_signer_public_only_accounts(manager, &accounts)?
     87         .into_iter()
     88         .filter(|account| !active_session_account_ids.contains(account.account_id.as_str()))
     89     {
     90         manager.remove_account(&account.account_id)?;
     91     }
     92 
     93     if let Some(record) = state.pending_session().cloned()
     94         && load_client_secret(paths, record.client_account_id()).is_err()
     95     {
     96         state.remove_pending_session();
     97         dirty = true;
     98     }
     99 
    100     let stale_active_sessions = state
    101         .sessions
    102         .iter()
    103         .filter(|record| record.status == RadrootsAppRemoteSignerSessionStatus::Active)
    104         .filter_map(|record| {
    105             let account_id = record.account_id()?;
    106             (!account_ids.contains(account_id)).then_some(record.clone())
    107         })
    108         .collect::<Vec<_>>();
    109 
    110     for session in stale_active_sessions {
    111         remove_client_secret(paths, session.client_account_id())?;
    112         let Some(account_id) = session.account_id() else {
    113             continue;
    114         };
    115         state.remove_active_session_for_account_id(account_id);
    116         dirty = true;
    117     }
    118 
    119     if dirty || load.recovered_from_corruption {
    120         save_sessions(paths, &state)?;
    121     }
    122 
    123     Ok(())
    124 }
    125 
    126 pub(crate) fn store_pending_session(
    127     paths: &DesktopRemoteSignerPaths,
    128     pending: &RadrootsAppRemoteSignerPendingSession,
    129 ) -> Result<(), DesktopRemoteSignerError> {
    130     let client_account_id = pending.record.client_account_id().to_owned();
    131     store_client_secret(
    132         paths,
    133         client_account_id.as_str(),
    134         pending.client_secret_key_hex.as_str(),
    135     )?;
    136 
    137     let mut state = load_sessions(paths)?;
    138     if let Err(error) = state.upsert_pending(pending.record.clone()) {
    139         let _ = remove_client_secret(paths, client_account_id.as_str());
    140         return Err(error.into());
    141     }
    142     if let Err(error) = save_sessions(paths, &state) {
    143         let _ = remove_client_secret(paths, client_account_id.as_str());
    144         return Err(error);
    145     }
    146 
    147     Ok(())
    148 }
    149 
    150 pub(crate) fn load_pending_session(
    151     paths: &DesktopRemoteSignerPaths,
    152 ) -> Result<Option<RadrootsAppRemoteSignerPendingSession>, DesktopRemoteSignerError> {
    153     let state = load_sessions(paths)?;
    154     let Some(record) = state.pending_session().cloned() else {
    155         return Ok(None);
    156     };
    157     let client_secret_key_hex = load_client_secret(paths, record.client_account_id())?;
    158     Ok(Some(RadrootsAppRemoteSignerPendingSession {
    159         record,
    160         client_secret_key_hex,
    161     }))
    162 }
    163 
    164 pub(crate) fn clear_pending_session(
    165     paths: &DesktopRemoteSignerPaths,
    166 ) -> Result<Option<RadrootsAppRemoteSignerSessionRecord>, DesktopRemoteSignerError> {
    167     let state = load_sessions(paths)?;
    168     let Some(record) = state.pending_session().cloned() else {
    169         return Ok(None);
    170     };
    171     let mut next_state = state.clone();
    172     let removed = next_state.remove_pending_session();
    173     if removed.is_none() {
    174         return Err(DesktopRemoteSignerError::State(
    175             "remote signer pending session record cleanup could not complete".to_owned(),
    176         ));
    177     }
    178     save_sessions(paths, &next_state)?;
    179 
    180     if let Err(error) = remove_client_secret(paths, record.client_account_id()) {
    181         return Err(DesktopRemoteSignerError::State(format!(
    182             "remote signer pending session record was removed but session secret cleanup needs retry: {error}"
    183         )));
    184     }
    185 
    186     Ok(removed)
    187 }
    188 
    189 pub(crate) fn activate_pending_session(
    190     manager: &RadrootsNostrAccountsManager,
    191     paths: &DesktopRemoteSignerPaths,
    192     client_account_id: &str,
    193     approved: &RadrootsAppRemoteSignerApprovedSession,
    194 ) -> Result<(), DesktopRemoteSignerError> {
    195     manager.upsert_public_identity(
    196         approved.user_identity.clone(),
    197         Some(REMOTE_SIGNER_LABEL.to_owned()),
    198         true,
    199     )?;
    200 
    201     let activation_result = (|| -> Result<(), DesktopRemoteSignerError> {
    202         let mut state = load_sessions(paths)?;
    203         state
    204             .activate_session(
    205                 client_account_id,
    206                 approved.user_identity.clone(),
    207                 approved.relays.clone(),
    208                 approved.approved_permissions.clone(),
    209             )
    210             .ok_or_else(|| {
    211                 DesktopRemoteSignerError::State(
    212                     "pending remote signer session disappeared before activation".to_owned(),
    213                 )
    214             })?;
    215         save_sessions(paths, &state)
    216     })();
    217 
    218     if let Err(error) = activation_result {
    219         if let Err(rollback_error) = manager.remove_account(&approved.user_identity.id) {
    220             return Err(DesktopRemoteSignerError::State(format!(
    221                 "{error}. remote signer account rollback needs retry: {rollback_error}"
    222             )));
    223         }
    224         return Err(error);
    225     }
    226 
    227     Ok(())
    228 }
    229 
    230 pub(crate) fn purge_all_state(
    231     paths: &DesktopRemoteSignerPaths,
    232 ) -> Result<(), DesktopRemoteSignerError> {
    233     let load = load_sessions_with_recovery(paths)?;
    234     for record in &load.state.sessions {
    235         remove_client_secret(paths, record.client_account_id())?;
    236     }
    237     purge_client_secret_namespace(paths)?;
    238     remove_sessions_file_if_present(paths.sessions_path.as_path())?;
    239     Ok(())
    240 }
    241 
    242 pub(crate) fn apply_remote_signer_custody(
    243     projection: AppIdentityProjection,
    244     paths: &DesktopRemoteSignerPaths,
    245 ) -> Result<AppIdentityProjection, DesktopRemoteSignerError> {
    246     let active_account_ids = active_remote_signer_account_ids(paths)?;
    247     if active_account_ids.is_empty() {
    248         return Ok(projection);
    249     }
    250 
    251     let mut projection = projection;
    252     for account in &mut projection.roster {
    253         if active_account_ids.contains(account.account_id.as_str()) {
    254             account.custody = AccountCustody::RemoteSigner;
    255         }
    256     }
    257     if let Some(selected_account) = projection.selected_account.as_mut()
    258         && active_account_ids.contains(selected_account.account.account_id.as_str())
    259     {
    260         selected_account.account.custody = AccountCustody::RemoteSigner;
    261     }
    262 
    263     Ok(projection)
    264 }
    265 
    266 fn active_remote_signer_account_ids(
    267     paths: &DesktopRemoteSignerPaths,
    268 ) -> Result<HashSet<String>, DesktopRemoteSignerError> {
    269     Ok(load_sessions(paths)?
    270         .sessions
    271         .into_iter()
    272         .filter(|record| record.status == RadrootsAppRemoteSignerSessionStatus::Active)
    273         .filter_map(|record| record.account_id().map(ToOwned::to_owned))
    274         .collect())
    275 }
    276 
    277 fn remote_signer_public_only_accounts(
    278     manager: &RadrootsNostrAccountsManager,
    279     accounts: &[RadrootsNostrAccountRecord],
    280 ) -> Result<Vec<RadrootsNostrAccountRecord>, DesktopRemoteSignerError> {
    281     let mut stale = Vec::new();
    282     for account in accounts {
    283         if account.label.as_deref() != Some(REMOTE_SIGNER_LABEL) {
    284             continue;
    285         }
    286         if manager.get_signing_identity(&account.account_id)?.is_none() {
    287             stale.push(account.clone());
    288         }
    289     }
    290     Ok(stale)
    291 }
    292 
    293 fn client_secret_vault(paths: &DesktopRemoteSignerPaths) -> RadrootsProtectedFileSecretVault {
    294     RadrootsProtectedFileSecretVault::new(paths.client_secret_root.as_path())
    295 }
    296 
    297 fn client_secret_slot(client_account_id: &str) -> Result<String, DesktopRemoteSignerError> {
    298     let account_id = RadrootsIdentityId::parse(client_account_id)?;
    299     Ok(account_secret_slot(&account_id))
    300 }
    301 
    302 fn store_client_secret(
    303     paths: &DesktopRemoteSignerPaths,
    304     client_account_id: &str,
    305     secret_key_hex: &str,
    306 ) -> Result<(), DesktopRemoteSignerError> {
    307     let slot = client_secret_slot(client_account_id)?;
    308     client_secret_vault(paths).store_secret(slot.as_str(), secret_key_hex)?;
    309     Ok(())
    310 }
    311 
    312 fn load_client_secret(
    313     paths: &DesktopRemoteSignerPaths,
    314     client_account_id: &str,
    315 ) -> Result<String, DesktopRemoteSignerError> {
    316     let slot = client_secret_slot(client_account_id)?;
    317     client_secret_vault(paths)
    318         .load_secret(slot.as_str())?
    319         .ok_or_else(|| {
    320             DesktopRemoteSignerError::State("remote signer session secret is missing".to_owned())
    321         })
    322 }
    323 
    324 fn remove_client_secret(
    325     paths: &DesktopRemoteSignerPaths,
    326     client_account_id: &str,
    327 ) -> Result<(), DesktopRemoteSignerError> {
    328     let slot = client_secret_slot(client_account_id)?;
    329     client_secret_vault(paths).remove_secret(slot.as_str())?;
    330     Ok(())
    331 }
    332 
    333 fn purge_client_secret_namespace(
    334     paths: &DesktopRemoteSignerPaths,
    335 ) -> Result<(), DesktopRemoteSignerError> {
    336     match fs::remove_dir_all(paths.client_secret_root.as_path()) {
    337         Ok(()) => Ok(()),
    338         Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
    339         Err(error) => Err(DesktopRemoteSignerError::State(format!(
    340             "failed to purge remote signer client secret namespace: {error}"
    341         ))),
    342     }
    343 }
    344 
    345 fn load_sessions(
    346     paths: &DesktopRemoteSignerPaths,
    347 ) -> Result<RadrootsAppRemoteSignerSessionStoreState, DesktopRemoteSignerError> {
    348     Ok(RadrootsAppRemoteSignerSessionStoreState::load(
    349         paths.sessions_path.as_path(),
    350     )?)
    351 }
    352 
    353 fn load_sessions_with_recovery(
    354     paths: &DesktopRemoteSignerPaths,
    355 ) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, DesktopRemoteSignerError> {
    356     Ok(
    357         RadrootsAppRemoteSignerSessionStoreState::load_with_recovery(
    358             paths.sessions_path.as_path(),
    359         )?,
    360     )
    361 }
    362 
    363 fn save_sessions(
    364     paths: &DesktopRemoteSignerPaths,
    365     state: &RadrootsAppRemoteSignerSessionStoreState,
    366 ) -> Result<(), DesktopRemoteSignerError> {
    367     Ok(state.save(paths.sessions_path.as_path())?)
    368 }
    369 
    370 fn remove_sessions_file_if_present(path: &Path) -> Result<(), DesktopRemoteSignerError> {
    371     match fs::remove_file(path) {
    372         Ok(()) => Ok(()),
    373         Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
    374         Err(error) => Err(DesktopRemoteSignerError::State(format!(
    375             "failed to remove remote signer session store: {error}"
    376         ))),
    377     }
    378 }
    379 
    380 #[cfg(test)]
    381 mod tests {
    382     use std::env;
    383     use std::time::{SystemTime, UNIX_EPOCH};
    384 
    385     use radroots_app_remote_signer::{
    386         RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession,
    387         RadrootsAppRemoteSignerSessionRecord, radroots_app_remote_signer_requested_permissions,
    388     };
    389     use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
    390     use radroots_nostr_accounts::prelude::{
    391         RadrootsNostrAccountStatus, RadrootsNostrAccountsManager,
    392     };
    393 
    394     use super::{
    395         DesktopRemoteSignerPaths, activate_pending_session, apply_remote_signer_custody,
    396         clear_pending_session, load_pending_session, purge_all_state, reconcile_startup,
    397         store_pending_session,
    398     };
    399 
    400     const CLIENT_SECRET_KEY_HEX: &str =
    401         "1111111111111111111111111111111111111111111111111111111111111111";
    402     const SIGNER_SECRET_KEY_HEX: &str =
    403         "2222222222222222222222222222222222222222222222222222222222222222";
    404     const USER_SECRET_KEY_HEX: &str =
    405         "3333333333333333333333333333333333333333333333333333333333333333";
    406 
    407     fn public_identity(secret_key_hex: &str) -> RadrootsIdentityPublic {
    408         RadrootsIdentity::from_secret_key_str(secret_key_hex)
    409             .expect("identity")
    410             .to_public()
    411     }
    412 
    413     fn temp_paths(label: &str) -> DesktopRemoteSignerPaths {
    414         let unique = SystemTime::now()
    415             .duration_since(UNIX_EPOCH)
    416             .expect("system time")
    417             .as_nanos();
    418         let root = env::temp_dir()
    419             .join("radroots-app-desktop-remote-signer")
    420             .join(format!("{label}-{unique}"));
    421         DesktopRemoteSignerPaths {
    422             sessions_path: root.join("data").join("remote-signer-sessions.json"),
    423             client_secret_root: root.join("secrets").join("remote_signer"),
    424         }
    425     }
    426 
    427     fn pending_session() -> RadrootsAppRemoteSignerPendingSession {
    428         RadrootsAppRemoteSignerPendingSession {
    429             record: RadrootsAppRemoteSignerSessionRecord::pending(
    430                 public_identity(CLIENT_SECRET_KEY_HEX),
    431                 public_identity(SIGNER_SECRET_KEY_HEX),
    432                 vec!["ws://127.0.0.1:8080".to_owned()],
    433             ),
    434             client_secret_key_hex: CLIENT_SECRET_KEY_HEX.to_owned(),
    435         }
    436     }
    437 
    438     #[test]
    439     fn pending_session_round_trips_with_client_secret() {
    440         let paths = temp_paths("pending");
    441         let pending = pending_session();
    442 
    443         store_pending_session(&paths, &pending).expect("store pending");
    444         let restored = load_pending_session(&paths)
    445             .expect("load pending")
    446             .expect("pending session");
    447 
    448         assert_eq!(
    449             restored.record.client_account_id(),
    450             pending.record.client_account_id()
    451         );
    452         assert_eq!(
    453             restored.record.signer_identity.id,
    454             pending.record.signer_identity.id
    455         );
    456         assert_eq!(restored.record.relays, pending.record.relays);
    457         assert_eq!(restored.record.status, pending.record.status);
    458         assert_eq!(
    459             restored.client_secret_key_hex,
    460             pending.client_secret_key_hex
    461         );
    462 
    463         clear_pending_session(&paths).expect("clear pending");
    464         assert!(
    465             load_pending_session(&paths)
    466                 .expect("load after clear")
    467                 .is_none()
    468         );
    469     }
    470 
    471     #[test]
    472     fn activating_pending_session_upserts_selected_remote_signer_account() {
    473         let paths = temp_paths("activate");
    474         let manager = RadrootsNostrAccountsManager::new_in_memory();
    475         let pending = pending_session();
    476         let approved = RadrootsAppRemoteSignerApprovedSession {
    477             user_identity: public_identity(USER_SECRET_KEY_HEX),
    478             relays: vec!["ws://127.0.0.1:8080".to_owned()],
    479             approved_permissions: radroots_app_remote_signer_requested_permissions(),
    480         };
    481 
    482         store_pending_session(&paths, &pending).expect("store pending");
    483         activate_pending_session(
    484             &manager,
    485             &paths,
    486             pending.record.client_account_id(),
    487             &approved,
    488         )
    489         .expect("activate pending");
    490 
    491         let selected = match manager
    492             .default_account_status()
    493             .expect("selected account status")
    494         {
    495             RadrootsNostrAccountStatus::NotConfigured => panic!("configured account"),
    496             RadrootsNostrAccountStatus::PublicOnly { account }
    497             | RadrootsNostrAccountStatus::Ready { account } => account,
    498         };
    499         assert_eq!(
    500             selected.account_id.as_str(),
    501             approved.user_identity.id.as_str()
    502         );
    503         assert_eq!(selected.label.as_deref(), Some("remote signer"));
    504 
    505         let projection = apply_remote_signer_custody(
    506             radroots_app_view::AppIdentityProjection::ready(
    507                 vec![radroots_app_view::AccountSummary {
    508                     account_id: approved.user_identity.id.to_string(),
    509                     npub: approved.user_identity.public_key_npub.clone(),
    510                     label: Some("remote signer".to_owned()),
    511                     custody: radroots_app_view::AccountCustody::LocalManaged,
    512                 }],
    513                 radroots_app_view::SelectedAccountProjection::new(
    514                     radroots_app_view::AccountSummary {
    515                         account_id: approved.user_identity.id.to_string(),
    516                         npub: approved.user_identity.public_key_npub.clone(),
    517                         label: Some("remote signer".to_owned()),
    518                         custody: radroots_app_view::AccountCustody::LocalManaged,
    519                     },
    520                     radroots_app_view::SelectedSurfaceProjection::default(),
    521                     radroots_app_view::FarmerActivationProjection::inactive(),
    522                 ),
    523             ),
    524             &paths,
    525         )
    526         .expect("decorate projection");
    527         assert_eq!(
    528             projection
    529                 .selected_account
    530                 .as_ref()
    531                 .expect("selected")
    532                 .account
    533                 .custody,
    534             radroots_app_view::AccountCustody::RemoteSigner
    535         );
    536     }
    537 
    538     #[test]
    539     fn reconcile_startup_removes_orphan_remote_signer_account_and_pending_without_secret() {
    540         let paths = temp_paths("reconcile");
    541         let manager = RadrootsNostrAccountsManager::new_in_memory();
    542         let pending = pending_session();
    543         store_pending_session(&paths, &pending).expect("store pending");
    544         clear_pending_session(&paths).expect("clear pending");
    545         store_pending_session(&paths, &pending).expect("store pending again");
    546         manager
    547             .upsert_public_identity(
    548                 public_identity(USER_SECRET_KEY_HEX),
    549                 Some("remote signer".to_owned()),
    550                 true,
    551             )
    552             .expect("upsert remote signer account");
    553 
    554         purge_all_state(&paths).expect("purge all");
    555         reconcile_startup(&manager, &paths).expect("reconcile startup");
    556 
    557         assert!(manager.list_accounts().expect("accounts").is_empty());
    558         assert!(
    559             load_pending_session(&paths)
    560                 .expect("load pending")
    561                 .is_none()
    562         );
    563     }
    564 }