lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

evaluation.rs (22137B)


      1 use crate::error::RadrootsNostrSignerError;
      2 use crate::model::{
      3     RadrootsNostrSignerAuthChallenge, RadrootsNostrSignerConnectionDraft,
      4     RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPendingRequest,
      5     RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerRequestId,
      6 };
      7 use nostr::{PublicKey, RelayUrl};
      8 use radroots_identity::RadrootsIdentityPublic;
      9 use radroots_nostr_connect::prelude::{
     10     RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
     11     RadrootsNostrConnectRemoteSessionCapability, RadrootsNostrConnectRequest,
     12 };
     13 
     14 #[derive(Debug, Clone)]
     15 pub enum RadrootsNostrSignerSessionLookup {
     16     None,
     17     Connection(Box<RadrootsNostrSignerConnectionRecord>),
     18     Ambiguous(Vec<RadrootsNostrSignerConnectionRecord>),
     19 }
     20 
     21 #[derive(Debug, Clone, PartialEq, Eq)]
     22 pub struct RadrootsNostrSignerConnectProposal {
     23     pub client_public_key: PublicKey,
     24     pub connect_secret: Option<String>,
     25     pub requested_permissions: RadrootsNostrConnectPermissions,
     26 }
     27 
     28 #[derive(Debug, Clone)]
     29 pub enum RadrootsNostrSignerConnectEvaluation {
     30     ExistingConnection(Box<RadrootsNostrSignerConnectionRecord>),
     31     RegistrationRequired(RadrootsNostrSignerConnectProposal),
     32 }
     33 
     34 #[derive(Debug, Clone, PartialEq, Eq)]
     35 pub enum RadrootsNostrSignerRequestResponseHint {
     36     None,
     37     Pong,
     38     UserPublicKey(PublicKey),
     39     RemoteSessionCapability(RadrootsNostrConnectRemoteSessionCapability),
     40     RelayList(Vec<RelayUrl>),
     41 }
     42 
     43 #[derive(Debug, Clone, PartialEq, Eq)]
     44 pub enum RadrootsNostrSignerRequestAction {
     45     Allowed {
     46         required_permission: Option<RadrootsNostrConnectPermission>,
     47         response_hint: RadrootsNostrSignerRequestResponseHint,
     48     },
     49     Denied {
     50         reason: String,
     51     },
     52     Challenged {
     53         auth_challenge: RadrootsNostrSignerAuthChallenge,
     54         pending_request: RadrootsNostrSignerPendingRequest,
     55     },
     56 }
     57 
     58 #[derive(Debug, Clone)]
     59 pub struct RadrootsNostrSignerRequestEvaluation {
     60     pub request_id: RadrootsNostrSignerRequestId,
     61     pub method: RadrootsNostrConnectMethod,
     62     pub connection: RadrootsNostrSignerConnectionRecord,
     63     pub audit: RadrootsNostrSignerRequestAuditRecord,
     64     pub action: RadrootsNostrSignerRequestAction,
     65 }
     66 
     67 impl RadrootsNostrSignerConnectProposal {
     68     pub fn into_connection_draft(
     69         self,
     70         user_identity: RadrootsIdentityPublic,
     71     ) -> RadrootsNostrSignerConnectionDraft {
     72         let mut draft =
     73             RadrootsNostrSignerConnectionDraft::new(self.client_public_key, user_identity)
     74                 .with_requested_permissions(self.requested_permissions);
     75         if let Some(connect_secret) = self.connect_secret {
     76             draft = draft.with_connect_secret(connect_secret);
     77         }
     78         draft
     79     }
     80 }
     81 
     82 impl RadrootsNostrSignerRequestEvaluation {
     83     pub fn denied_reason(&self) -> Option<&str> {
     84         match &self.action {
     85             RadrootsNostrSignerRequestAction::Denied { reason } => Some(reason.as_str()),
     86             _ => None,
     87         }
     88     }
     89 }
     90 
     91 impl RadrootsNostrSignerRequestAction {
     92     pub fn audit_message(&self) -> Option<String> {
     93         match self {
     94             Self::Allowed { .. } => None,
     95             Self::Denied { reason } => Some(reason.clone()),
     96             Self::Challenged { .. } => Some("auth challenge required".into()),
     97         }
     98     }
     99 }
    100 
    101 pub(crate) fn required_permission_for_request(
    102     request: &RadrootsNostrConnectRequest,
    103 ) -> Option<RadrootsNostrConnectPermission> {
    104     match request {
    105         RadrootsNostrConnectRequest::Connect { .. }
    106         | RadrootsNostrConnectRequest::GetPublicKey
    107         | RadrootsNostrConnectRequest::GetSessionCapability
    108         | RadrootsNostrConnectRequest::Ping => None,
    109         RadrootsNostrConnectRequest::SignEvent(unsigned_event) => {
    110             Some(RadrootsNostrConnectPermission::with_parameter(
    111                 RadrootsNostrConnectMethod::SignEvent,
    112                 format!("kind:{}", unsigned_event.kind.as_u16()),
    113             ))
    114         }
    115         RadrootsNostrConnectRequest::Nip04Encrypt { .. } => Some(
    116             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt),
    117         ),
    118         RadrootsNostrConnectRequest::Nip04Decrypt { .. } => Some(
    119             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Decrypt),
    120         ),
    121         RadrootsNostrConnectRequest::Nip44Encrypt { .. } => Some(
    122             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt),
    123         ),
    124         RadrootsNostrConnectRequest::Nip44Decrypt { .. } => Some(
    125             RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Decrypt),
    126         ),
    127         RadrootsNostrConnectRequest::SwitchRelays => Some(RadrootsNostrConnectPermission::new(
    128             RadrootsNostrConnectMethod::SwitchRelays,
    129         )),
    130         RadrootsNostrConnectRequest::Custom { method, .. } => {
    131             Some(RadrootsNostrConnectPermission::new(method.clone()))
    132         }
    133     }
    134 }
    135 
    136 pub(crate) fn request_allowed_by_permissions(
    137     granted_permissions: &RadrootsNostrConnectPermissions,
    138     request: &RadrootsNostrConnectRequest,
    139 ) -> bool {
    140     let Some(required_permission) = required_permission_for_request(request) else {
    141         return true;
    142     };
    143 
    144     granted_permissions
    145         .as_slice()
    146         .iter()
    147         .any(|permission| permission_matches(permission, &required_permission))
    148 }
    149 
    150 pub(crate) fn response_hint_for_request(
    151     connection: &RadrootsNostrSignerConnectionRecord,
    152     request: &RadrootsNostrConnectRequest,
    153 ) -> Result<RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerError> {
    154     match request {
    155         RadrootsNostrConnectRequest::GetPublicKey => {
    156             Ok(RadrootsNostrSignerRequestResponseHint::UserPublicKey(
    157                 identity_public_key(&connection.user_identity)?,
    158             ))
    159         }
    160         RadrootsNostrConnectRequest::GetSessionCapability => Ok(
    161             RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(
    162                 RadrootsNostrConnectRemoteSessionCapability {
    163                     user_public_key: identity_public_key(&connection.user_identity)?,
    164                     relays: connection.relays.clone(),
    165                     permissions: connection.effective_permissions(),
    166                 },
    167             ),
    168         ),
    169         RadrootsNostrConnectRequest::Ping => Ok(RadrootsNostrSignerRequestResponseHint::Pong),
    170         RadrootsNostrConnectRequest::SwitchRelays => Ok(
    171             RadrootsNostrSignerRequestResponseHint::RelayList(connection.relays.clone()),
    172         ),
    173         _ => Ok(RadrootsNostrSignerRequestResponseHint::None),
    174     }
    175 }
    176 
    177 fn permission_matches(
    178     granted_permission: &RadrootsNostrConnectPermission,
    179     required_permission: &RadrootsNostrConnectPermission,
    180 ) -> bool {
    181     if granted_permission.method != required_permission.method {
    182         return false;
    183     }
    184 
    185     match (
    186         &granted_permission.method,
    187         granted_permission.parameter.as_deref(),
    188         required_permission.parameter.as_deref(),
    189     ) {
    190         (RadrootsNostrConnectMethod::SignEvent, None, _) => true,
    191         (RadrootsNostrConnectMethod::SignEvent, Some(parameter), Some(required)) => {
    192             parameter == required || parameter == sign_event_kind_suffix(required)
    193         }
    194         (_, None, _) => true,
    195         (_, Some(parameter), Some(required)) => parameter == required,
    196         (_, Some(_), None) => false,
    197     }
    198 }
    199 
    200 fn sign_event_kind_suffix(value: &str) -> &str {
    201     value.strip_prefix("kind:").unwrap_or(value)
    202 }
    203 
    204 fn identity_public_key(
    205     identity: &RadrootsIdentityPublic,
    206 ) -> Result<PublicKey, RadrootsNostrSignerError> {
    207     PublicKey::parse(identity.public_key_hex.as_str())
    208         .or_else(|_| PublicKey::from_hex(identity.public_key_hex.as_str()))
    209         .map_err(|_| {
    210             RadrootsNostrSignerError::InvalidState("user identity public key is invalid".into())
    211         })
    212 }
    213 
    214 #[cfg(test)]
    215 #[cfg_attr(coverage_nightly, coverage(off))]
    216 mod tests {
    217     use super::*;
    218     use crate::test_support::{
    219         api_primary_https, fixture_alice_identity, fixture_alice_public_key, fixture_bob_identity,
    220         fixture_carol_public_key, fixture_diego_identity, primary_relay, synthetic_public_identity,
    221         synthetic_public_key,
    222     };
    223     use nostr::{PublicKey, Timestamp, UnsignedEvent};
    224     use radroots_identity::RadrootsIdentityPublic;
    225     use serde_json::json;
    226 
    227     fn public_identity(index: u32) -> RadrootsIdentityPublic {
    228         synthetic_public_identity(index)
    229     }
    230 
    231     fn public_key(index: u32) -> PublicKey {
    232         synthetic_public_key(index)
    233     }
    234 
    235     fn unsigned_event(kind: u16) -> UnsignedEvent {
    236         serde_json::from_value(json!({
    237             "pubkey": fixture_alice_public_key().to_hex(),
    238             "created_at": Timestamp::from(1).as_secs(),
    239             "kind": kind,
    240             "tags": [],
    241             "content": "hello"
    242         }))
    243         .expect("unsigned event")
    244     }
    245 
    246     fn connection() -> RadrootsNostrSignerConnectionRecord {
    247         RadrootsNostrSignerConnectionRecord::new(
    248             crate::model::RadrootsNostrSignerConnectionId::new_v7(),
    249             fixture_bob_identity(),
    250             RadrootsNostrSignerConnectionDraft::new(
    251                 fixture_carol_public_key(),
    252                 fixture_diego_identity(),
    253             )
    254             .with_relays(vec![primary_relay()]),
    255             1,
    256         )
    257     }
    258 
    259     #[cfg_attr(coverage_nightly, coverage(off))]
    260     fn assert_action_audit_message_none(action: &RadrootsNostrSignerRequestAction) {
    261         assert_eq!(action.audit_message(), None);
    262     }
    263 
    264     #[cfg_attr(coverage_nightly, coverage(off))]
    265     fn assert_response_hint_none(hint: RadrootsNostrSignerRequestResponseHint) {
    266         match hint {
    267             RadrootsNostrSignerRequestResponseHint::None => {}
    268             other => panic!("unexpected response hint: {other:?}"),
    269         }
    270     }
    271 
    272     #[cfg_attr(coverage_nightly, coverage(off))]
    273     fn assert_response_hint_pong(hint: RadrootsNostrSignerRequestResponseHint) {
    274         match hint {
    275             RadrootsNostrSignerRequestResponseHint::Pong => {}
    276             other => panic!("unexpected response hint: {other:?}"),
    277         }
    278     }
    279 
    280     #[cfg_attr(coverage_nightly, coverage(off))]
    281     fn assert_response_hint_user_public_key(hint: RadrootsNostrSignerRequestResponseHint) {
    282         match hint {
    283             RadrootsNostrSignerRequestResponseHint::UserPublicKey(_) => {}
    284             other => panic!("unexpected response hint: {other:?}"),
    285         }
    286     }
    287 
    288     #[cfg_attr(coverage_nightly, coverage(off))]
    289     fn assert_response_hint_remote_session_capability(
    290         hint: RadrootsNostrSignerRequestResponseHint,
    291         expected_permissions: RadrootsNostrConnectPermissions,
    292     ) {
    293         match hint {
    294             RadrootsNostrSignerRequestResponseHint::RemoteSessionCapability(capability) => {
    295                 let expected_public_key =
    296                     PublicKey::parse(fixture_diego_identity().public_key_hex.as_str())
    297                         .expect("user public key");
    298                 assert_eq!(
    299                     capability.user_public_key.to_hex(),
    300                     expected_public_key.to_hex()
    301                 );
    302                 assert_eq!(capability.relays, vec![primary_relay()]);
    303                 assert_eq!(capability.permissions, expected_permissions);
    304             }
    305             other => panic!("unexpected response hint: {other:?}"),
    306         }
    307     }
    308 
    309     #[test]
    310     fn connect_proposal_builds_connection_draft() {
    311         let requested_permissions: RadrootsNostrConnectPermissions =
    312             vec![RadrootsNostrConnectPermission::new(
    313                 RadrootsNostrConnectMethod::Nip04Encrypt,
    314             )]
    315             .into();
    316         let proposal = RadrootsNostrSignerConnectProposal {
    317             client_public_key: public_key(5),
    318             connect_secret: Some("secret".into()),
    319             requested_permissions: requested_permissions.clone(),
    320         };
    321 
    322         let draft = proposal.into_connection_draft(fixture_alice_identity());
    323 
    324         assert_eq!(draft.connect_secret.as_deref(), Some("secret"));
    325         assert_eq!(draft.requested_permissions, requested_permissions);
    326 
    327         let no_secret = RadrootsNostrSignerConnectProposal {
    328             client_public_key: public_key(7),
    329             connect_secret: None,
    330             requested_permissions: RadrootsNostrConnectPermissions::default(),
    331         }
    332         .into_connection_draft(fixture_bob_identity());
    333         assert!(no_secret.connect_secret.is_none());
    334     }
    335 
    336     #[test]
    337     fn request_action_audit_message_and_denied_reason_cover_variants() {
    338         let denied = RadrootsNostrSignerRequestAction::Denied {
    339             reason: "unauthorized".into(),
    340         };
    341         let challenged = RadrootsNostrSignerRequestAction::Challenged {
    342             auth_challenge: crate::model::RadrootsNostrSignerAuthChallenge::new(
    343                 api_primary_https(),
    344                 1,
    345             )
    346             .expect("challenge"),
    347             pending_request: crate::model::RadrootsNostrSignerPendingRequest::new(
    348                 radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new(
    349                     "req-1",
    350                     RadrootsNostrConnectRequest::Ping,
    351                 ),
    352                 1,
    353             )
    354             .expect("pending"),
    355         };
    356         let evaluation = RadrootsNostrSignerRequestEvaluation {
    357             request_id: RadrootsNostrSignerRequestId::new_v7(),
    358             method: RadrootsNostrConnectMethod::Ping,
    359             connection: connection(),
    360             audit: crate::model::RadrootsNostrSignerRequestAuditRecord::new(
    361                 RadrootsNostrSignerRequestId::new_v7(),
    362                 crate::model::RadrootsNostrSignerConnectionId::new_v7(),
    363                 RadrootsNostrConnectMethod::Ping,
    364                 crate::model::RadrootsNostrSignerRequestDecision::Denied,
    365                 Some("unauthorized".into()),
    366                 1,
    367             ),
    368             action: denied.clone(),
    369         };
    370 
    371         assert_eq!(denied.audit_message().as_deref(), Some("unauthorized"));
    372         assert_eq!(
    373             challenged.audit_message().as_deref(),
    374             Some("auth challenge required")
    375         );
    376         assert_eq!(evaluation.denied_reason(), Some("unauthorized"));
    377         assert_action_audit_message_none(&RadrootsNostrSignerRequestAction::Allowed {
    378             required_permission: None,
    379             response_hint: RadrootsNostrSignerRequestResponseHint::None,
    380         });
    381     }
    382 
    383     #[test]
    384     fn request_permission_matching_covers_generic_and_sign_event_forms() {
    385         let kind_one = unsigned_event(1);
    386         let kind_two = unsigned_event(2);
    387         let sign_kind = RadrootsNostrConnectPermission::with_parameter(
    388             RadrootsNostrConnectMethod::SignEvent,
    389             "kind:1",
    390         );
    391         let sign_numeric = RadrootsNostrConnectPermission::with_parameter(
    392             RadrootsNostrConnectMethod::SignEvent,
    393             "1",
    394         );
    395         let sign_all = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SignEvent);
    396         let nip44 = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt);
    397 
    398         assert!(request_allowed_by_permissions(
    399             &vec![sign_kind.clone()].into(),
    400             &RadrootsNostrConnectRequest::SignEvent(kind_one.clone()),
    401         ));
    402         assert!(request_allowed_by_permissions(
    403             &vec![sign_numeric].into(),
    404             &RadrootsNostrConnectRequest::SignEvent(kind_one),
    405         ));
    406         assert!(request_allowed_by_permissions(
    407             &vec![sign_all].into(),
    408             &RadrootsNostrConnectRequest::SignEvent(kind_two),
    409         ));
    410         assert!(!request_allowed_by_permissions(
    411             &vec![sign_kind, nip44].into(),
    412             &RadrootsNostrConnectRequest::Nip04Encrypt {
    413                 public_key: public_key(7),
    414                 plaintext: "hello".into(),
    415             },
    416         ));
    417         assert!(request_allowed_by_permissions(
    418             &RadrootsNostrConnectPermissions::default(),
    419             &RadrootsNostrConnectRequest::Ping,
    420         ));
    421         assert!(!request_allowed_by_permissions(
    422             &vec![RadrootsNostrConnectPermission::with_parameter(
    423                 RadrootsNostrConnectMethod::Custom("do_thing".into()),
    424                 "scoped",
    425             )]
    426             .into(),
    427             &RadrootsNostrConnectRequest::Custom {
    428                 method: RadrootsNostrConnectMethod::Custom("do_thing".into()),
    429                 params: vec!["value".into()],
    430             },
    431         ));
    432         assert!(permission_matches(
    433             &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt),
    434             &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt),
    435         ));
    436         assert!(permission_matches(
    437             &RadrootsNostrConnectPermission::with_parameter(
    438                 RadrootsNostrConnectMethod::Custom("scoped".into()),
    439                 "alpha",
    440             ),
    441             &RadrootsNostrConnectPermission::with_parameter(
    442                 RadrootsNostrConnectMethod::Custom("scoped".into()),
    443                 "alpha",
    444             ),
    445         ));
    446     }
    447 
    448     #[test]
    449     fn required_permission_and_response_hint_cover_request_variants() {
    450         let connection = connection();
    451         let public_key = public_key(8);
    452         let connect = RadrootsNostrConnectRequest::Connect {
    453             remote_signer_public_key: public_key,
    454             secret: Some("secret".into()),
    455             requested_permissions: RadrootsNostrConnectPermissions::default(),
    456         };
    457         let ping = RadrootsNostrConnectRequest::Ping;
    458         let get_public_key = RadrootsNostrConnectRequest::GetPublicKey;
    459         let get_session_capability = RadrootsNostrConnectRequest::GetSessionCapability;
    460         let switch_relays = RadrootsNostrConnectRequest::SwitchRelays;
    461         let sign_event = RadrootsNostrConnectRequest::SignEvent(unsigned_event(7));
    462         let custom = RadrootsNostrConnectRequest::Custom {
    463             method: RadrootsNostrConnectMethod::Custom("do_thing".into()),
    464             params: vec!["a".into()],
    465         };
    466 
    467         assert!(required_permission_for_request(&connect).is_none());
    468         assert!(required_permission_for_request(&ping).is_none());
    469         assert!(required_permission_for_request(&get_public_key).is_none());
    470         assert!(required_permission_for_request(&get_session_capability).is_none());
    471         assert_eq!(
    472             required_permission_for_request(&RadrootsNostrConnectRequest::Nip04Decrypt {
    473                 public_key,
    474                 ciphertext: "cipher".into(),
    475             })
    476             .expect("nip04 decrypt permission")
    477             .to_string(),
    478             "nip04_decrypt"
    479         );
    480         assert_eq!(
    481             required_permission_for_request(&RadrootsNostrConnectRequest::Nip44Encrypt {
    482                 public_key,
    483                 plaintext: "hello".into(),
    484             })
    485             .expect("nip44 encrypt permission")
    486             .to_string(),
    487             "nip44_encrypt"
    488         );
    489         assert_eq!(
    490             required_permission_for_request(&RadrootsNostrConnectRequest::Nip44Decrypt {
    491                 public_key,
    492                 ciphertext: "cipher".into(),
    493             })
    494             .expect("nip44 decrypt permission")
    495             .to_string(),
    496             "nip44_decrypt"
    497         );
    498         assert_eq!(
    499             required_permission_for_request(&switch_relays)
    500                 .expect("switch relays permission")
    501                 .to_string(),
    502             "switch_relays"
    503         );
    504         assert_eq!(
    505             required_permission_for_request(&sign_event)
    506                 .expect("sign_event permission")
    507                 .to_string(),
    508             "sign_event:kind:7"
    509         );
    510         assert_eq!(
    511             required_permission_for_request(&custom)
    512                 .expect("custom permission")
    513                 .to_string(),
    514             "do_thing"
    515         );
    516 
    517         assert_response_hint_none(
    518             response_hint_for_request(
    519                 &connection,
    520                 &RadrootsNostrConnectRequest::Nip04Decrypt {
    521                     public_key,
    522                     ciphertext: "cipher".into(),
    523                 },
    524             )
    525             .expect("nip04 response hint"),
    526         );
    527         assert_response_hint_pong(
    528             response_hint_for_request(&connection, &ping).expect("ping hint"),
    529         );
    530         assert_response_hint_user_public_key(
    531             response_hint_for_request(&connection, &get_public_key).expect("pubkey hint"),
    532         );
    533         assert_response_hint_remote_session_capability(
    534             response_hint_for_request(&connection, &get_session_capability)
    535                 .expect("capability hint"),
    536             connection.effective_permissions(),
    537         );
    538         assert_eq!(
    539             response_hint_for_request(&connection, &switch_relays).expect("relay hint"),
    540             RadrootsNostrSignerRequestResponseHint::RelayList(vec![primary_relay()])
    541         );
    542     }
    543 
    544     #[test]
    545     fn invalid_identity_public_key_returns_invalid_state() {
    546         let mut identity = public_identity(9);
    547         identity.public_key_hex = "invalid".into();
    548 
    549         let err = identity_public_key(&identity).expect_err("invalid identity");
    550         assert!(
    551             err.to_string()
    552                 .contains("user identity public key is invalid")
    553         );
    554 
    555         let mut invalid_connection = connection();
    556         invalid_connection.user_identity.public_key_hex = "invalid".into();
    557         let err = response_hint_for_request(
    558             &invalid_connection,
    559             &RadrootsNostrConnectRequest::GetPublicKey,
    560         )
    561         .expect_err("invalid get_public_key response hint");
    562         assert!(
    563             err.to_string()
    564                 .contains("user identity public key is invalid")
    565         );
    566 
    567         let err = response_hint_for_request(
    568             &invalid_connection,
    569             &RadrootsNostrConnectRequest::GetSessionCapability,
    570         )
    571         .expect_err("invalid get_session_capability response hint");
    572         assert!(
    573             err.to_string()
    574                 .contains("user identity public key is invalid")
    575         );
    576     }
    577 }