app

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

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 }