session.rs (18233B)
1 use crate::error::RadrootsAppRemoteSignerError; 2 use radroots_identity::RadrootsIdentityPublic; 3 use radroots_nostr_connect::prelude::{ 4 RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, 5 }; 6 use serde::{Deserialize, Serialize}; 7 use std::io::Write; 8 use std::path::Path; 9 use std::time::{SystemTime, UNIX_EPOCH}; 10 11 pub const RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION: u32 = 1; 12 13 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 14 pub enum RadrootsAppRemoteSignerSessionStatus { 15 PendingApproval, 16 Active, 17 } 18 19 #[derive(Debug, Clone, Serialize, Deserialize)] 20 pub struct RadrootsAppRemoteSignerSessionRecord { 21 pub client_identity: RadrootsIdentityPublic, 22 pub signer_identity: RadrootsIdentityPublic, 23 #[serde(default, skip_serializing_if = "Option::is_none")] 24 pub user_identity: Option<RadrootsIdentityPublic>, 25 pub relays: Vec<String>, 26 #[serde(default)] 27 pub approved_permissions: RadrootsNostrConnectPermissions, 28 pub status: RadrootsAppRemoteSignerSessionStatus, 29 pub created_at_unix: u64, 30 pub updated_at_unix: u64, 31 } 32 33 #[derive(Debug, Clone, Serialize, Deserialize)] 34 pub struct RadrootsAppRemoteSignerSessionStoreState { 35 pub version: u32, 36 pub sessions: Vec<RadrootsAppRemoteSignerSessionRecord>, 37 } 38 39 #[derive(Debug, Clone)] 40 pub struct RadrootsAppRemoteSignerSessionStoreLoadResult { 41 pub state: RadrootsAppRemoteSignerSessionStoreState, 42 pub recovered_from_corruption: bool, 43 } 44 45 impl Default for RadrootsAppRemoteSignerSessionStoreState { 46 fn default() -> Self { 47 Self { 48 version: RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION, 49 sessions: Vec::new(), 50 } 51 } 52 } 53 54 impl RadrootsAppRemoteSignerSessionRecord { 55 pub fn pending( 56 client_identity: RadrootsIdentityPublic, 57 signer_identity: RadrootsIdentityPublic, 58 relays: Vec<String>, 59 ) -> Self { 60 let now = now_unix_secs(); 61 Self { 62 client_identity, 63 signer_identity, 64 user_identity: None, 65 relays, 66 approved_permissions: RadrootsNostrConnectPermissions::default(), 67 status: RadrootsAppRemoteSignerSessionStatus::PendingApproval, 68 created_at_unix: now, 69 updated_at_unix: now, 70 } 71 } 72 73 pub fn account_id(&self) -> Option<&str> { 74 self.user_identity 75 .as_ref() 76 .map(|identity| identity.id.as_str()) 77 } 78 79 pub fn client_account_id(&self) -> &str { 80 self.client_identity.id.as_str() 81 } 82 83 pub fn approved_permission_labels(&self) -> Vec<String> { 84 self.approved_permissions 85 .as_slice() 86 .iter() 87 .map(ToString::to_string) 88 .collect() 89 } 90 91 pub fn allows_sign_event_kind1(&self) -> bool { 92 self.approved_permissions 93 .as_slice() 94 .iter() 95 .any(|permission| { 96 permission_matches( 97 permission, 98 &RadrootsNostrConnectPermission::with_parameter( 99 RadrootsNostrConnectMethod::SignEvent, 100 "kind:1", 101 ), 102 ) 103 }) 104 } 105 106 pub fn allows_switch_relays(&self) -> bool { 107 self.approved_permissions 108 .as_slice() 109 .iter() 110 .any(|permission| { 111 permission_matches( 112 permission, 113 &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays), 114 ) 115 }) 116 } 117 } 118 119 impl RadrootsAppRemoteSignerSessionStoreState { 120 pub fn load(path: &Path) -> Result<Self, RadrootsAppRemoteSignerError> { 121 Ok(Self::load_with_recovery(path)?.state) 122 } 123 124 pub fn load_with_recovery( 125 path: &Path, 126 ) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, RadrootsAppRemoteSignerError> { 127 match std::fs::read(path) { 128 Ok(contents) => Self::load_bytes(path, contents), 129 Err(error) if error.kind() == std::io::ErrorKind::NotFound => { 130 Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { 131 state: Self::default(), 132 recovered_from_corruption: false, 133 }) 134 } 135 Err(error) => Err(RadrootsAppRemoteSignerError::SessionStoreIo( 136 error.to_string(), 137 )), 138 } 139 } 140 141 pub fn save(&self, path: &Path) -> Result<(), RadrootsAppRemoteSignerError> { 142 if let Some(parent) = path.parent() { 143 std::fs::create_dir_all(parent) 144 .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; 145 } 146 let json = serde_json::to_string_pretty(self) 147 .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; 148 let temp_path = temporary_store_path(path); 149 let mut file = std::fs::OpenOptions::new() 150 .write(true) 151 .create_new(true) 152 .open(temp_path.as_path()) 153 .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; 154 if let Err(error) = (|| -> Result<(), std::io::Error> { 155 file.write_all(json.as_bytes())?; 156 file.flush()?; 157 file.sync_all() 158 })() { 159 let _ = std::fs::remove_file(temp_path.as_path()); 160 return Err(RadrootsAppRemoteSignerError::SessionStoreIo( 161 error.to_string(), 162 )); 163 } 164 165 #[cfg(windows)] 166 if path.exists() { 167 std::fs::remove_file(path) 168 .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?; 169 } 170 171 std::fs::rename(temp_path.as_path(), path) 172 .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string())) 173 } 174 175 pub fn pending_session(&self) -> Option<&RadrootsAppRemoteSignerSessionRecord> { 176 self.sessions 177 .iter() 178 .find(|record| record.status == RadrootsAppRemoteSignerSessionStatus::PendingApproval) 179 } 180 181 pub fn active_session_for_account_id( 182 &self, 183 account_id: &str, 184 ) -> Option<&RadrootsAppRemoteSignerSessionRecord> { 185 self.sessions.iter().find(|record| { 186 record.status == RadrootsAppRemoteSignerSessionStatus::Active 187 && record.account_id() == Some(account_id) 188 }) 189 } 190 191 pub fn upsert_pending( 192 &mut self, 193 pending: RadrootsAppRemoteSignerSessionRecord, 194 ) -> Result<(), RadrootsAppRemoteSignerError> { 195 if self.pending_session().is_some() { 196 return Err(RadrootsAppRemoteSignerError::PendingSessionExists); 197 } 198 self.sessions 199 .retain(|record| record.client_account_id() != pending.client_account_id()); 200 self.sessions.push(pending); 201 Ok(()) 202 } 203 204 pub fn activate_session( 205 &mut self, 206 client_account_id: &str, 207 user_identity: RadrootsIdentityPublic, 208 relays: Vec<String>, 209 approved_permissions: RadrootsNostrConnectPermissions, 210 ) -> Option<RadrootsAppRemoteSignerSessionRecord> { 211 let now = now_unix_secs(); 212 self.sessions.retain(|record| { 213 !(record.status == RadrootsAppRemoteSignerSessionStatus::Active 214 && record.account_id() == Some(user_identity.id.as_str())) 215 }); 216 let record = self 217 .sessions 218 .iter_mut() 219 .find(|record| record.client_account_id() == client_account_id)?; 220 record.user_identity = Some(user_identity); 221 record.relays = relays; 222 record.approved_permissions = approved_permissions; 223 record.status = RadrootsAppRemoteSignerSessionStatus::Active; 224 record.updated_at_unix = now; 225 Some(record.clone()) 226 } 227 228 pub fn remove_pending_session(&mut self) -> Option<RadrootsAppRemoteSignerSessionRecord> { 229 let index = self.sessions.iter().position(|record| { 230 record.status == RadrootsAppRemoteSignerSessionStatus::PendingApproval 231 })?; 232 Some(self.sessions.remove(index)) 233 } 234 235 pub fn remove_active_session_for_account_id( 236 &mut self, 237 account_id: &str, 238 ) -> Option<RadrootsAppRemoteSignerSessionRecord> { 239 let index = self.sessions.iter().position(|record| { 240 record.status == RadrootsAppRemoteSignerSessionStatus::Active 241 && record.account_id() == Some(account_id) 242 })?; 243 Some(self.sessions.remove(index)) 244 } 245 246 fn load_bytes( 247 path: &Path, 248 contents: Vec<u8>, 249 ) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, RadrootsAppRemoteSignerError> { 250 let contents = String::from_utf8(contents).map_err(|error| { 251 RadrootsAppRemoteSignerError::InvalidSessionStore(format!( 252 "session store was not valid utf-8: {error}" 253 )) 254 }); 255 256 let contents = match contents { 257 Ok(contents) => contents, 258 Err(_) => { 259 quarantine_invalid_store(path)?; 260 return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { 261 state: Self::default(), 262 recovered_from_corruption: true, 263 }); 264 } 265 }; 266 267 let state = match serde_json::from_str::<Self>(&contents) { 268 Ok(state) => state, 269 Err(_) => { 270 quarantine_invalid_store(path)?; 271 return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { 272 state: Self::default(), 273 recovered_from_corruption: true, 274 }); 275 } 276 }; 277 278 if state.version != RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION { 279 quarantine_invalid_store(path)?; 280 return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { 281 state: Self::default(), 282 recovered_from_corruption: true, 283 }); 284 } 285 286 Ok(RadrootsAppRemoteSignerSessionStoreLoadResult { 287 state, 288 recovered_from_corruption: false, 289 }) 290 } 291 } 292 293 fn permission_matches( 294 granted_permission: &RadrootsNostrConnectPermission, 295 required_permission: &RadrootsNostrConnectPermission, 296 ) -> bool { 297 if granted_permission.method != required_permission.method { 298 return false; 299 } 300 301 match ( 302 &granted_permission.method, 303 granted_permission.parameter.as_deref(), 304 required_permission.parameter.as_deref(), 305 ) { 306 (RadrootsNostrConnectMethod::SignEvent, None, _) => true, 307 (RadrootsNostrConnectMethod::SignEvent, Some(parameter), Some(required)) => { 308 parameter == required || parameter == sign_event_kind_suffix(required) 309 } 310 (_, None, _) => true, 311 (_, Some(parameter), Some(required)) => parameter == required, 312 (_, Some(_), None) => false, 313 } 314 } 315 316 fn sign_event_kind_suffix(value: &str) -> &str { 317 value.strip_prefix("kind:").unwrap_or(value) 318 } 319 320 fn now_unix_secs() -> u64 { 321 SystemTime::now() 322 .duration_since(UNIX_EPOCH) 323 .map(|duration| duration.as_secs()) 324 .unwrap_or(0) 325 } 326 327 fn temporary_store_path(path: &Path) -> std::path::PathBuf { 328 let process_id = std::process::id(); 329 let timestamp = SystemTime::now() 330 .duration_since(UNIX_EPOCH) 331 .map(|duration| duration.as_nanos()) 332 .unwrap_or(0); 333 path.with_extension(format!("json.tmp-{process_id}-{timestamp}")) 334 } 335 336 fn quarantine_invalid_store(path: &Path) -> Result<(), RadrootsAppRemoteSignerError> { 337 let process_id = std::process::id(); 338 let timestamp = SystemTime::now() 339 .duration_since(UNIX_EPOCH) 340 .map(|duration| duration.as_secs()) 341 .unwrap_or(0); 342 let file_name = path 343 .file_name() 344 .and_then(|name| name.to_str()) 345 .unwrap_or("remote-signer-sessions.json"); 346 let quarantine_path = 347 path.with_file_name(format!("{file_name}.corrupt-{timestamp}-{process_id}")); 348 std::fs::rename(path, quarantine_path.as_path()) 349 .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string())) 350 } 351 352 #[cfg(test)] 353 mod tests { 354 use super::*; 355 use radroots_identity::RadrootsIdentity; 356 use radroots_nostr_connect::prelude::{ 357 RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, 358 }; 359 360 const CLIENT_SECRET_KEY_HEX: &str = 361 "1111111111111111111111111111111111111111111111111111111111111111"; 362 const SIGNER_SECRET_KEY_HEX: &str = 363 "2222222222222222222222222222222222222222222222222222222222222222"; 364 const USER_SECRET_KEY_HEX: &str = 365 "3333333333333333333333333333333333333333333333333333333333333333"; 366 367 fn public_identity(secret_key_hex: &str) -> RadrootsIdentityPublic { 368 RadrootsIdentity::from_secret_key_str(secret_key_hex) 369 .expect("identity") 370 .to_public() 371 } 372 373 fn pending_record() -> RadrootsAppRemoteSignerSessionRecord { 374 RadrootsAppRemoteSignerSessionRecord::pending( 375 public_identity(CLIENT_SECRET_KEY_HEX), 376 public_identity(SIGNER_SECRET_KEY_HEX), 377 vec!["wss://relay.example.com".to_owned()], 378 ) 379 } 380 381 #[test] 382 fn pending_store_round_trips() { 383 let temp = tempfile::tempdir().expect("tempdir"); 384 let path = temp.path().join("sessions.json"); 385 let mut state = RadrootsAppRemoteSignerSessionStoreState::default(); 386 state.upsert_pending(pending_record()).expect("pending"); 387 state.save(path.as_path()).expect("save"); 388 389 let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load"); 390 391 assert_eq!(loaded.sessions.len(), 1); 392 assert_eq!( 393 loaded.sessions[0].status, 394 RadrootsAppRemoteSignerSessionStatus::PendingApproval 395 ); 396 } 397 398 #[test] 399 fn activate_session_replaces_pending_with_active_user_identity() { 400 let mut state = RadrootsAppRemoteSignerSessionStoreState::default(); 401 let pending = pending_record(); 402 let client_account_id = pending.client_account_id().to_owned(); 403 state.upsert_pending(pending).expect("pending"); 404 405 let user_public = public_identity(USER_SECRET_KEY_HEX); 406 let active = state 407 .activate_session( 408 client_account_id.as_str(), 409 user_public.clone(), 410 vec!["wss://relay.updated.example".to_owned()], 411 vec![ 412 RadrootsNostrConnectPermission::with_parameter( 413 RadrootsNostrConnectMethod::SignEvent, 414 "kind:1", 415 ), 416 RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays), 417 ] 418 .into(), 419 ) 420 .expect("active"); 421 422 assert_eq!(active.status, RadrootsAppRemoteSignerSessionStatus::Active); 423 assert_eq!(active.account_id(), Some(user_public.id.as_str())); 424 assert_eq!( 425 active.relays, 426 vec!["wss://relay.updated.example".to_owned()] 427 ); 428 assert_eq!( 429 active.approved_permission_labels(), 430 vec!["sign_event:kind:1".to_owned(), "switch_relays".to_owned()] 431 ); 432 assert!(active.allows_sign_event_kind1()); 433 assert!(active.allows_switch_relays()); 434 assert!(state.pending_session().is_none()); 435 } 436 437 #[test] 438 fn remove_active_session_matches_user_account_id() { 439 let mut state = RadrootsAppRemoteSignerSessionStoreState::default(); 440 let pending = pending_record(); 441 let client_account_id = pending.client_account_id().to_owned(); 442 state.upsert_pending(pending).expect("pending"); 443 let user_public = public_identity(USER_SECRET_KEY_HEX); 444 state.activate_session( 445 client_account_id.as_str(), 446 user_public.clone(), 447 vec!["wss://relay.updated.example".to_owned()], 448 RadrootsNostrConnectPermissions::default(), 449 ); 450 451 let removed = state 452 .remove_active_session_for_account_id(user_public.id.as_str()) 453 .expect("removed"); 454 455 assert_eq!(removed.account_id(), Some(user_public.id.as_str())); 456 assert!(state.sessions.is_empty()); 457 } 458 459 #[test] 460 fn load_recovers_from_invalid_json_by_quarantining_store() { 461 let temp = tempfile::tempdir().expect("tempdir"); 462 let path = temp.path().join("sessions.json"); 463 std::fs::write(path.as_path(), "{invalid").expect("write invalid"); 464 465 let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load"); 466 467 assert!(loaded.sessions.is_empty()); 468 assert!(!path.exists()); 469 let quarantined = std::fs::read_dir(temp.path()) 470 .expect("read dir") 471 .filter_map(|entry| entry.ok()) 472 .any(|entry| entry.file_name().to_string_lossy().contains("corrupt")); 473 assert!(quarantined); 474 } 475 476 #[test] 477 fn load_recovers_from_unsupported_schema_version() { 478 let temp = tempfile::tempdir().expect("tempdir"); 479 let path = temp.path().join("sessions.json"); 480 std::fs::write(path.as_path(), r#"{"version":999,"sessions":[]}"#).expect("write invalid"); 481 482 let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load"); 483 484 assert_eq!( 485 loaded.version, 486 RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION 487 ); 488 assert!(loaded.sessions.is_empty()); 489 assert!(!path.exists()); 490 } 491 492 #[test] 493 fn active_session_permission_helpers_respect_sign_event_and_switch_relays() { 494 let mut record = pending_record(); 495 record.user_identity = Some(public_identity(USER_SECRET_KEY_HEX)); 496 record.status = RadrootsAppRemoteSignerSessionStatus::Active; 497 record.approved_permissions = vec![RadrootsNostrConnectPermission::with_parameter( 498 RadrootsNostrConnectMethod::SignEvent, 499 "1", 500 )] 501 .into(); 502 503 assert!(record.allows_sign_event_kind1()); 504 assert!(!record.allows_switch_relays()); 505 } 506 }