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 }