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 }