policy.rs (35566B)
1 use std::collections::{BTreeSet, HashMap, VecDeque}; 2 use std::sync::{Arc, Mutex}; 3 use std::time::{SystemTime, UNIX_EPOCH}; 4 5 use nostr::PublicKey; 6 use radroots_nostr_connect::prelude::{ 7 RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, 8 RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, 9 }; 10 use radroots_nostr_signer::prelude::{ 11 RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend, 12 RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerManager, 13 RadrootsNostrSignerNip46ConnectDecision, RadrootsNostrSignerNip46Policy, 14 }; 15 16 use crate::config::{MycConnectionApproval, MycPolicyConfig}; 17 use crate::error::MycError; 18 19 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 20 pub enum MycConnectDecision { 21 Allow, 22 RequireApproval, 23 Deny, 24 } 25 26 #[derive(Debug, Clone)] 27 pub struct MycPolicyContext { 28 default_connect_decision: MycConnectDecision, 29 trusted_client_pubkeys: BTreeSet<String>, 30 denied_client_pubkeys: BTreeSet<String>, 31 permission_ceiling: RadrootsNostrConnectPermissions, 32 allowed_sign_event_kinds: BTreeSet<u16>, 33 auth_url: Option<String>, 34 auth_pending_ttl_secs: u64, 35 auth_authorized_ttl_secs: Option<u64>, 36 reauth_after_inactivity_secs: Option<u64>, 37 connect_rate_limiter: Option<MycPolicyRateLimiter>, 38 auth_challenge_rate_limiter: Option<MycPolicyRateLimiter>, 39 } 40 41 #[derive(Debug, Clone)] 42 struct MycPolicyRateLimiter { 43 window_secs: u64, 44 max_attempts: usize, 45 entries: Arc<Mutex<HashMap<String, VecDeque<u64>>>>, 46 } 47 48 impl MycPolicyContext { 49 pub fn from_config(config: &MycPolicyConfig) -> Result<Self, MycError> { 50 Ok(Self { 51 default_connect_decision: match config.connection_approval { 52 MycConnectionApproval::NotRequired => MycConnectDecision::Allow, 53 MycConnectionApproval::ExplicitUser => MycConnectDecision::RequireApproval, 54 MycConnectionApproval::Deny => MycConnectDecision::Deny, 55 }, 56 trusted_client_pubkeys: normalize_public_key_set(&config.trusted_client_pubkeys)?, 57 denied_client_pubkeys: normalize_public_key_set(&config.denied_client_pubkeys)?, 58 permission_ceiling: normalize_permissions(config.permission_ceiling.clone()), 59 allowed_sign_event_kinds: config.allowed_sign_event_kinds.iter().copied().collect(), 60 auth_url: config.auth_url.clone(), 61 auth_pending_ttl_secs: config.auth_pending_ttl_secs, 62 auth_authorized_ttl_secs: config.auth_authorized_ttl_secs, 63 reauth_after_inactivity_secs: config.reauth_after_inactivity_secs, 64 connect_rate_limiter: build_rate_limiter( 65 config.connect_rate_limit_window_secs, 66 config.connect_rate_limit_max_attempts, 67 ), 68 auth_challenge_rate_limiter: build_rate_limiter( 69 config.auth_challenge_rate_limit_window_secs, 70 config.auth_challenge_rate_limit_max_attempts, 71 ), 72 }) 73 } 74 75 pub fn default_approval_requirement(&self) -> RadrootsNostrSignerApprovalRequirement { 76 match self.default_connect_decision { 77 MycConnectDecision::Allow => RadrootsNostrSignerApprovalRequirement::NotRequired, 78 MycConnectDecision::RequireApproval | MycConnectDecision::Deny => { 79 RadrootsNostrSignerApprovalRequirement::ExplicitUser 80 } 81 } 82 } 83 84 pub fn connect_decision(&self, client_public_key: &PublicKey) -> MycConnectDecision { 85 let client_public_key_hex = client_public_key.to_hex(); 86 if self.denied_client_pubkeys.contains(&client_public_key_hex) { 87 return MycConnectDecision::Deny; 88 } 89 if self.trusted_client_pubkeys.contains(&client_public_key_hex) { 90 return MycConnectDecision::Allow; 91 } 92 self.default_connect_decision 93 } 94 95 pub fn approval_requirement_for_client( 96 &self, 97 client_public_key: &PublicKey, 98 ) -> Option<RadrootsNostrSignerApprovalRequirement> { 99 match self.connect_decision(client_public_key) { 100 MycConnectDecision::Allow => Some(RadrootsNostrSignerApprovalRequirement::NotRequired), 101 MycConnectDecision::RequireApproval => { 102 Some(RadrootsNostrSignerApprovalRequirement::ExplicitUser) 103 } 104 MycConnectDecision::Deny => None, 105 } 106 } 107 108 pub fn connect_rate_limit_denied_reason( 109 &self, 110 client_public_key: &PublicKey, 111 ) -> Option<String> { 112 self.connect_rate_limiter.as_ref().and_then(|limiter| { 113 limiter 114 .check_and_record(&client_public_key.to_hex()) 115 .map(|retry_after_secs| throttled_reason("connect attempts", retry_after_secs)) 116 }) 117 } 118 119 pub fn auto_granted_permissions( 120 &self, 121 requested_permissions: &RadrootsNostrConnectPermissions, 122 ) -> RadrootsNostrConnectPermissions { 123 self.filtered_requested_permissions(requested_permissions) 124 } 125 126 pub fn filtered_requested_permissions( 127 &self, 128 requested_permissions: &RadrootsNostrConnectPermissions, 129 ) -> RadrootsNostrConnectPermissions { 130 let mut filtered = Vec::new(); 131 132 for permission in requested_permissions.as_slice() { 133 if permission.method == RadrootsNostrConnectMethod::SignEvent 134 && permission.parameter.is_none() 135 && !self.allowed_sign_event_kinds.is_empty() 136 { 137 for kind in &self.allowed_sign_event_kinds { 138 let candidate = RadrootsNostrConnectPermission::with_parameter( 139 RadrootsNostrConnectMethod::SignEvent, 140 format!("kind:{kind}"), 141 ); 142 if self.permission_within_policy(&candidate) { 143 filtered.push(candidate); 144 } 145 } 146 continue; 147 } 148 149 if self.permission_within_policy(permission) { 150 filtered.push(permission.clone()); 151 } 152 } 153 154 normalize_permissions(filtered.into()) 155 } 156 157 pub fn validate_operator_grants( 158 &self, 159 granted_permissions: RadrootsNostrConnectPermissions, 160 ) -> Result<RadrootsNostrConnectPermissions, MycError> { 161 let granted_permissions = normalize_permissions(granted_permissions); 162 let invalid_permissions = granted_permissions 163 .as_slice() 164 .iter() 165 .filter(|permission| !self.permission_within_policy(permission)) 166 .map(ToString::to_string) 167 .collect::<Vec<_>>(); 168 169 if invalid_permissions.is_empty() { 170 Ok(granted_permissions) 171 } else { 172 Err(MycError::InvalidOperation(format!( 173 "granted permissions exceed the configured policy ceiling: {}", 174 invalid_permissions.join(", ") 175 ))) 176 } 177 } 178 179 pub fn prepare_request<B: RadrootsNostrSignerBackend>( 180 &self, 181 backend: &B, 182 connection: &RadrootsNostrSignerConnectionRecord, 183 request_message: &RadrootsNostrConnectRequestMessage, 184 ) -> Result<Option<String>, MycError> { 185 if self.client_is_denied(&connection.client_public_key) { 186 return Ok(Some("client public key denied by policy".to_owned())); 187 } 188 189 if let Some(reason) = self.request_denied_reason(&request_message.request) { 190 return Ok(Some(reason)); 191 } 192 193 if connection.auth_state 194 == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending 195 && self.auth_challenge_is_expired(connection) 196 { 197 if self.request_uses_automatic_auth(connection, &request_message.request) { 198 if let Some(reason) = 199 self.require_auth_challenge_with_guardrails(backend, connection)? 200 { 201 return Ok(Some(reason)); 202 } 203 } else { 204 return Ok(Some( 205 "auth challenge expired; require a new auth challenge".to_owned(), 206 )); 207 } 208 } else if self.should_require_fresh_auth(connection, &request_message.request) { 209 if let Some(reason) = 210 self.require_auth_challenge_with_guardrails(backend, connection)? 211 { 212 return Ok(Some(reason)); 213 } 214 } 215 216 Ok(None) 217 } 218 219 pub fn ensure_authorize_auth_challenge_allowed( 220 &self, 221 connection: &RadrootsNostrSignerConnectionRecord, 222 ) -> Result<(), MycError> { 223 if connection.auth_state 224 == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending 225 && self.auth_challenge_is_expired(connection) 226 { 227 return Err(MycError::InvalidOperation( 228 "auth challenge expired; require a new auth challenge".to_owned(), 229 )); 230 } 231 Ok(()) 232 } 233 234 pub fn cleanup_stale_sessions( 235 &self, 236 manager: &RadrootsNostrSignerManager, 237 ) -> Result<usize, MycError> { 238 let mut cleaned = 0usize; 239 for connection in manager.list_connections()? { 240 if !self.stale_session_requires_cleanup(&connection) { 241 continue; 242 } 243 self.require_auth_challenge_with_manager(manager, &connection)?; 244 cleaned += 1; 245 } 246 Ok(cleaned) 247 } 248 249 fn client_is_denied(&self, client_public_key: &PublicKey) -> bool { 250 self.denied_client_pubkeys 251 .contains(&client_public_key.to_hex()) 252 } 253 254 fn client_is_trusted(&self, client_public_key: &PublicKey) -> bool { 255 self.trusted_client_pubkeys 256 .contains(&client_public_key.to_hex()) 257 } 258 259 fn permission_within_policy(&self, permission: &RadrootsNostrConnectPermission) -> bool { 260 if permission.method == RadrootsNostrConnectMethod::SignEvent 261 && !self.allowed_sign_event_kinds.is_empty() 262 { 263 let Some(kind) = permission 264 .parameter 265 .as_deref() 266 .and_then(parse_sign_event_kind_parameter) 267 else { 268 return false; 269 }; 270 if !self.allowed_sign_event_kinds.contains(&kind) { 271 return false; 272 } 273 } 274 275 if self.permission_ceiling.is_empty() { 276 return true; 277 } 278 279 self.permission_ceiling 280 .as_slice() 281 .iter() 282 .any(|ceiling| permission_within_ceiling(permission, ceiling)) 283 } 284 285 fn request_denied_reason(&self, request: &RadrootsNostrConnectRequest) -> Option<String> { 286 if self.permission_ceiling.is_empty() 287 && (self.allowed_sign_event_kinds.is_empty() 288 || !matches!(request, RadrootsNostrConnectRequest::SignEvent(_))) 289 { 290 return None; 291 } 292 293 let required_permission = required_permission_for_request(request)?; 294 if self.permission_within_policy(&required_permission) { 295 None 296 } else { 297 Some(format!( 298 "request {} is outside the configured policy ceiling", 299 request.method() 300 )) 301 } 302 } 303 304 fn request_uses_automatic_auth( 305 &self, 306 connection: &RadrootsNostrSignerConnectionRecord, 307 request: &RadrootsNostrConnectRequest, 308 ) -> bool { 309 self.automatic_auth_enabled_for_connection(connection) && request_requires_auth(request) 310 } 311 312 fn should_require_fresh_auth( 313 &self, 314 connection: &RadrootsNostrSignerConnectionRecord, 315 request: &RadrootsNostrConnectRequest, 316 ) -> bool { 317 if !self.request_uses_automatic_auth(connection, request) { 318 return false; 319 } 320 321 if connection.auth_state 322 == radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Pending 323 { 324 return false; 325 } 326 327 let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else { 328 return true; 329 }; 330 let now_unix = now_unix_secs(); 331 332 if self 333 .auth_authorized_ttl_secs 334 .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl)) 335 { 336 return true; 337 } 338 339 self.reauth_after_inactivity_secs.is_some_and(|ttl| { 340 let Some(last_request_at_unix) = connection.last_request_at_unix else { 341 return false; 342 }; 343 now_unix > last_request_at_unix.saturating_add(ttl) 344 }) 345 } 346 347 fn auth_challenge_is_expired(&self, connection: &RadrootsNostrSignerConnectionRecord) -> bool { 348 let Some(auth_challenge) = connection.auth_challenge.as_ref() else { 349 return false; 350 }; 351 now_unix_secs() 352 > auth_challenge 353 .required_at_unix 354 .saturating_add(self.auth_pending_ttl_secs) 355 } 356 357 fn auth_url(&self) -> Result<&str, MycError> { 358 self.auth_url.as_deref().ok_or_else(|| { 359 MycError::InvalidOperation( 360 "automatic auth policy requires policy.auth_url to be configured".to_owned(), 361 ) 362 }) 363 } 364 365 fn automatic_auth_enabled_for_connection( 366 &self, 367 connection: &RadrootsNostrSignerConnectionRecord, 368 ) -> bool { 369 self.auth_url.is_some() && self.client_is_trusted(&connection.client_public_key) 370 } 371 372 fn require_auth_challenge_with_guardrails<B: RadrootsNostrSignerBackend>( 373 &self, 374 backend: &B, 375 connection: &RadrootsNostrSignerConnectionRecord, 376 ) -> Result<Option<String>, MycError> { 377 if let Some(retry_after_secs) = self 378 .auth_challenge_rate_limiter 379 .as_ref() 380 .and_then(|limiter| limiter.check_and_record(&connection.client_public_key.to_hex())) 381 { 382 return Ok(Some(throttled_reason( 383 "auth challenge issuance", 384 retry_after_secs, 385 ))); 386 } 387 self.require_auth_challenge_with_backend(backend, connection)?; 388 Ok(None) 389 } 390 391 fn require_auth_challenge_with_backend<B: RadrootsNostrSignerBackend>( 392 &self, 393 backend: &B, 394 connection: &RadrootsNostrSignerConnectionRecord, 395 ) -> Result<(), MycError> { 396 backend.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; 397 Ok(()) 398 } 399 400 fn require_auth_challenge_with_manager( 401 &self, 402 manager: &RadrootsNostrSignerManager, 403 connection: &RadrootsNostrSignerConnectionRecord, 404 ) -> Result<(), MycError> { 405 manager.require_auth_challenge(&connection.connection_id, self.auth_url()?)?; 406 Ok(()) 407 } 408 409 fn stale_session_requires_cleanup( 410 &self, 411 connection: &RadrootsNostrSignerConnectionRecord, 412 ) -> bool { 413 if connection.is_terminal() 414 || connection.auth_state 415 != radroots_nostr_signer::prelude::RadrootsNostrSignerAuthState::Authorized 416 || !self.automatic_auth_enabled_for_connection(connection) 417 { 418 return false; 419 } 420 421 let Some(last_authenticated_at_unix) = connection.last_authenticated_at_unix else { 422 return true; 423 }; 424 let now_unix = now_unix_secs(); 425 426 if self 427 .auth_authorized_ttl_secs 428 .is_some_and(|ttl| now_unix > last_authenticated_at_unix.saturating_add(ttl)) 429 { 430 return true; 431 } 432 433 self.reauth_after_inactivity_secs.is_some_and(|ttl| { 434 connection 435 .last_request_at_unix 436 .is_some_and(|last_request_at_unix| { 437 now_unix > last_request_at_unix.saturating_add(ttl) 438 }) 439 }) 440 } 441 } 442 443 impl<B: RadrootsNostrSignerBackend> RadrootsNostrSignerNip46Policy<B> for MycPolicyContext { 444 fn connect_decision( 445 &self, 446 client_public_key: &PublicKey, 447 ) -> RadrootsNostrSignerNip46ConnectDecision { 448 match self.connect_decision(client_public_key) { 449 MycConnectDecision::Allow => RadrootsNostrSignerNip46ConnectDecision::Allow, 450 MycConnectDecision::RequireApproval => { 451 RadrootsNostrSignerNip46ConnectDecision::RequireApproval 452 } 453 MycConnectDecision::Deny => RadrootsNostrSignerNip46ConnectDecision::Deny, 454 } 455 } 456 457 fn connect_rate_limit_denied_reason(&self, client_public_key: &PublicKey) -> Option<String> { 458 self.connect_rate_limit_denied_reason(client_public_key) 459 } 460 461 fn approval_requirement_for_client( 462 &self, 463 client_public_key: &PublicKey, 464 ) -> Option<RadrootsNostrSignerApprovalRequirement> { 465 self.approval_requirement_for_client(client_public_key) 466 } 467 468 fn filtered_requested_permissions( 469 &self, 470 requested_permissions: &RadrootsNostrConnectPermissions, 471 ) -> RadrootsNostrConnectPermissions { 472 self.filtered_requested_permissions(requested_permissions) 473 } 474 475 fn auto_granted_permissions( 476 &self, 477 requested_permissions: &RadrootsNostrConnectPermissions, 478 ) -> RadrootsNostrConnectPermissions { 479 self.auto_granted_permissions(requested_permissions) 480 } 481 482 fn prepare_request( 483 &self, 484 backend: &B, 485 connection: &RadrootsNostrSignerConnectionRecord, 486 request_message: &RadrootsNostrConnectRequestMessage, 487 ) -> Result<Option<String>, radroots_nostr_signer::prelude::RadrootsNostrSignerError> { 488 self.prepare_request(backend, connection, request_message) 489 .map_err(myc_policy_signer_error) 490 } 491 } 492 493 impl MycPolicyRateLimiter { 494 fn check_and_record(&self, key: &str) -> Option<u64> { 495 let now_unix = now_unix_secs(); 496 let mut guard = self 497 .entries 498 .lock() 499 .unwrap_or_else(|poisoned| poisoned.into_inner()); 500 let attempts = guard.entry(key.to_owned()).or_default(); 501 prune_attempts(attempts, now_unix, self.window_secs); 502 if attempts.len() >= self.max_attempts { 503 return Some( 504 attempts 505 .front() 506 .copied() 507 .map(|oldest_attempt_unix| { 508 oldest_attempt_unix 509 .saturating_add(self.window_secs) 510 .saturating_sub(now_unix) 511 .max(1) 512 }) 513 .unwrap_or(1), 514 ); 515 } 516 attempts.push_back(now_unix); 517 None 518 } 519 } 520 521 fn normalize_permissions( 522 permissions: RadrootsNostrConnectPermissions, 523 ) -> RadrootsNostrConnectPermissions { 524 let mut permissions = permissions.into_vec(); 525 permissions.sort(); 526 permissions.dedup(); 527 permissions.into() 528 } 529 530 fn normalize_public_key_set(values: &[String]) -> Result<BTreeSet<String>, MycError> { 531 values 532 .iter() 533 .map(|value| normalize_public_key_hex(value)) 534 .collect() 535 } 536 537 fn normalize_public_key_hex(value: &str) -> Result<String, MycError> { 538 let trimmed = value.trim(); 539 if trimmed.is_empty() { 540 return Err(MycError::InvalidConfig( 541 "policy client pubkeys must not contain empty values".to_owned(), 542 )); 543 } 544 let public_key = PublicKey::parse(trimmed) 545 .or_else(|_| PublicKey::from_hex(trimmed)) 546 .map_err(|_| { 547 MycError::InvalidConfig(format!( 548 "policy client pubkey `{trimmed}` is not a valid nostr public key" 549 )) 550 })?; 551 Ok(public_key.to_hex()) 552 } 553 554 fn required_permission_for_request( 555 request: &RadrootsNostrConnectRequest, 556 ) -> Option<RadrootsNostrConnectPermission> { 557 match request { 558 RadrootsNostrConnectRequest::Connect { .. } 559 | RadrootsNostrConnectRequest::GetPublicKey 560 | RadrootsNostrConnectRequest::GetSessionCapability 561 | RadrootsNostrConnectRequest::Ping => None, 562 RadrootsNostrConnectRequest::SignEvent(unsigned_event) => { 563 Some(RadrootsNostrConnectPermission::with_parameter( 564 RadrootsNostrConnectMethod::SignEvent, 565 format!("kind:{}", unsigned_event.kind.as_u16()), 566 )) 567 } 568 RadrootsNostrConnectRequest::Nip04Encrypt { .. } => Some( 569 RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), 570 ), 571 RadrootsNostrConnectRequest::Nip04Decrypt { .. } => Some( 572 RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Decrypt), 573 ), 574 RadrootsNostrConnectRequest::Nip44Encrypt { .. } => Some( 575 RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Encrypt), 576 ), 577 RadrootsNostrConnectRequest::Nip44Decrypt { .. } => Some( 578 RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip44Decrypt), 579 ), 580 RadrootsNostrConnectRequest::SwitchRelays => Some(RadrootsNostrConnectPermission::new( 581 RadrootsNostrConnectMethod::SwitchRelays, 582 )), 583 RadrootsNostrConnectRequest::Custom { method, .. } => { 584 Some(RadrootsNostrConnectPermission::new(method.clone())) 585 } 586 } 587 } 588 589 fn permission_within_ceiling( 590 permission: &RadrootsNostrConnectPermission, 591 ceiling: &RadrootsNostrConnectPermission, 592 ) -> bool { 593 if permission.method != ceiling.method { 594 return false; 595 } 596 597 match ( 598 &permission.method, 599 permission.parameter.as_deref(), 600 ceiling.parameter.as_deref(), 601 ) { 602 (RadrootsNostrConnectMethod::SignEvent, _, None) => true, 603 (RadrootsNostrConnectMethod::SignEvent, Some(parameter), Some(ceiling_parameter)) => { 604 sign_event_parameter_eq(parameter, ceiling_parameter) 605 } 606 (RadrootsNostrConnectMethod::SignEvent, None, Some(_)) => false, 607 (_, _, None) => true, 608 (_, Some(parameter), Some(ceiling_parameter)) => parameter == ceiling_parameter, 609 (_, None, Some(_)) => false, 610 } 611 } 612 613 fn sign_event_parameter_eq(left: &str, right: &str) -> bool { 614 parse_sign_event_kind_parameter(left) == parse_sign_event_kind_parameter(right) 615 } 616 617 fn parse_sign_event_kind_parameter(value: &str) -> Option<u16> { 618 value 619 .strip_prefix("kind:") 620 .unwrap_or(value) 621 .parse::<u16>() 622 .ok() 623 } 624 625 fn request_requires_auth(request: &RadrootsNostrConnectRequest) -> bool { 626 !matches!( 627 request, 628 RadrootsNostrConnectRequest::Connect { .. } 629 | RadrootsNostrConnectRequest::GetPublicKey 630 | RadrootsNostrConnectRequest::GetSessionCapability 631 | RadrootsNostrConnectRequest::Ping 632 ) 633 } 634 635 fn build_rate_limiter( 636 window_secs: Option<u64>, 637 max_attempts: Option<usize>, 638 ) -> Option<MycPolicyRateLimiter> { 639 match (window_secs, max_attempts) { 640 (Some(window_secs), Some(max_attempts)) => Some(MycPolicyRateLimiter { 641 window_secs, 642 max_attempts, 643 entries: Arc::new(Mutex::new(HashMap::new())), 644 }), 645 _ => None, 646 } 647 } 648 649 fn prune_attempts(attempts: &mut VecDeque<u64>, now_unix: u64, window_secs: u64) { 650 while attempts 651 .front() 652 .copied() 653 .is_some_and(|attempt_unix| now_unix > attempt_unix.saturating_add(window_secs)) 654 { 655 let _ = attempts.pop_front(); 656 } 657 } 658 659 fn throttled_reason(label: &str, retry_after_secs: u64) -> String { 660 format!("{label} throttled by policy; retry after {retry_after_secs}s") 661 } 662 663 fn now_unix_secs() -> u64 { 664 SystemTime::now() 665 .duration_since(UNIX_EPOCH) 666 .map(|duration| duration.as_secs()) 667 .unwrap_or_default() 668 } 669 670 fn myc_policy_signer_error( 671 error: MycError, 672 ) -> radroots_nostr_signer::prelude::RadrootsNostrSignerError { 673 radroots_nostr_signer::prelude::RadrootsNostrSignerError::InvalidState(error.to_string()) 674 } 675 676 #[cfg(test)] 677 mod tests { 678 use super::{MycConnectDecision, MycPolicyContext}; 679 use crate::config::{MycConnectionApproval, MycPolicyConfig}; 680 use nostr::PublicKey; 681 use radroots_identity::RadrootsIdentity; 682 use radroots_nostr_connect::prelude::{ 683 RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, 684 RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, 685 RadrootsNostrConnectRequestMessage, 686 }; 687 use radroots_nostr_signer::prelude::{ 688 RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerApprovalRequirement, 689 RadrootsNostrSignerAuthState, RadrootsNostrSignerConnectionDraft, 690 RadrootsNostrSignerManager, 691 }; 692 use serde_json::json; 693 use std::thread; 694 use std::time::Duration; 695 696 fn public_key(hex: &str) -> PublicKey { 697 PublicKey::parse(hex).expect("public key") 698 } 699 700 fn identity(secret_key: &str) -> RadrootsIdentity { 701 RadrootsIdentity::from_secret_key_str(secret_key).expect("identity") 702 } 703 704 fn in_memory_manager() -> RadrootsNostrSignerManager { 705 let manager = RadrootsNostrSignerManager::new_in_memory(); 706 manager 707 .set_signer_identity( 708 identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") 709 .to_public(), 710 ) 711 .expect("set signer identity"); 712 manager 713 } 714 715 fn backend_for(manager: &RadrootsNostrSignerManager) -> RadrootsNostrEmbeddedSignerBackend { 716 RadrootsNostrEmbeddedSignerBackend::new( 717 manager.clone(), 718 identity("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), 719 ) 720 .expect("backend") 721 } 722 723 fn register_connection( 724 manager: &RadrootsNostrSignerManager, 725 client_public_key: PublicKey, 726 ) -> radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionRecord { 727 manager 728 .register_connection( 729 RadrootsNostrSignerConnectionDraft::new( 730 client_public_key, 731 identity("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") 732 .to_public(), 733 ) 734 .with_requested_permissions( 735 vec![RadrootsNostrConnectPermission::with_parameter( 736 RadrootsNostrConnectMethod::SignEvent, 737 "kind:1", 738 )] 739 .into(), 740 ) 741 .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::NotRequired), 742 ) 743 .expect("register connection") 744 } 745 746 fn unsigned_event(kind: u16) -> nostr::UnsignedEvent { 747 serde_json::from_value(json!({ 748 "pubkey": public_key("1111111111111111111111111111111111111111111111111111111111111111").to_hex(), 749 "created_at": 1, 750 "kind": kind, 751 "tags": [], 752 "content": "hello" 753 })) 754 .expect("unsigned event") 755 } 756 757 #[test] 758 fn connect_decision_prefers_deny_then_trust_then_default() { 759 let mut config = MycPolicyConfig::default(); 760 config.connection_approval = MycConnectionApproval::ExplicitUser; 761 config.trusted_client_pubkeys = 762 vec!["2222222222222222222222222222222222222222222222222222222222222222".to_owned()]; 763 config.denied_client_pubkeys = 764 vec!["3333333333333333333333333333333333333333333333333333333333333333".to_owned()]; 765 let policy = MycPolicyContext::from_config(&config).expect("policy"); 766 767 assert_eq!( 768 policy.connect_decision(&public_key( 769 "2222222222222222222222222222222222222222222222222222222222222222" 770 )), 771 MycConnectDecision::Allow 772 ); 773 assert_eq!( 774 policy.connect_decision(&public_key( 775 "3333333333333333333333333333333333333333333333333333333333333333" 776 )), 777 MycConnectDecision::Deny 778 ); 779 assert_eq!( 780 policy.connect_decision(&public_key( 781 "4444444444444444444444444444444444444444444444444444444444444444" 782 )), 783 MycConnectDecision::RequireApproval 784 ); 785 } 786 787 #[test] 788 fn auto_granted_permissions_apply_policy_ceiling_and_kind_limits() { 789 let mut config = MycPolicyConfig::default(); 790 config.permission_ceiling = vec![ 791 RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), 792 RadrootsNostrConnectPermission::with_parameter( 793 RadrootsNostrConnectMethod::SignEvent, 794 "kind:1", 795 ), 796 ] 797 .into(); 798 config.allowed_sign_event_kinds = vec![1]; 799 let policy = MycPolicyContext::from_config(&config).expect("policy"); 800 801 let requested_permissions: RadrootsNostrConnectPermissions = vec![ 802 RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Nip04Encrypt), 803 RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SignEvent), 804 RadrootsNostrConnectPermission::with_parameter( 805 RadrootsNostrConnectMethod::SignEvent, 806 "kind:2", 807 ), 808 ] 809 .into(); 810 let filtered = policy.auto_granted_permissions(&requested_permissions); 811 812 assert_eq!(filtered.to_string(), "sign_event:kind:1,nip04_encrypt"); 813 } 814 815 #[test] 816 fn request_denied_reason_applies_sign_event_kind_limits() { 817 let mut config = MycPolicyConfig::default(); 818 config.allowed_sign_event_kinds = vec![1]; 819 let policy = MycPolicyContext::from_config(&config).expect("policy"); 820 let manager = in_memory_manager(); 821 let backend = backend_for(&manager); 822 let connection = register_connection( 823 &manager, 824 public_key("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), 825 ); 826 827 let denied = policy 828 .prepare_request( 829 &backend, 830 &connection, 831 &RadrootsNostrConnectRequestMessage::new( 832 "request-1", 833 RadrootsNostrConnectRequest::SignEvent(unsigned_event(2)), 834 ), 835 ) 836 .expect("prepare request"); 837 838 assert_eq!( 839 denied, 840 Some("request sign_event is outside the configured policy ceiling".to_owned()) 841 ); 842 } 843 844 #[test] 845 fn validate_operator_grants_rejects_out_of_policy_permissions() { 846 let mut config = MycPolicyConfig::default(); 847 config.permission_ceiling = 848 RadrootsNostrConnectPermissions::from(vec![RadrootsNostrConnectPermission::new( 849 RadrootsNostrConnectMethod::Nip04Encrypt, 850 )]); 851 let policy = MycPolicyContext::from_config(&config).expect("policy"); 852 853 let error = policy 854 .validate_operator_grants( 855 vec![RadrootsNostrConnectPermission::new( 856 RadrootsNostrConnectMethod::Nip44Encrypt, 857 )] 858 .into(), 859 ) 860 .expect_err("grant outside ceiling"); 861 assert!( 862 error 863 .to_string() 864 .contains("granted permissions exceed the configured policy ceiling") 865 ); 866 } 867 868 #[test] 869 fn prepare_request_requires_fresh_auth_after_authorized_ttl() { 870 let client_public_key = 871 public_key("2222222222222222222222222222222222222222222222222222222222222222"); 872 let mut config = MycPolicyConfig::default(); 873 config.trusted_client_pubkeys = vec![client_public_key.to_hex()]; 874 config.auth_url = Some("https://auth.example".to_owned()); 875 config.auth_authorized_ttl_secs = Some(1); 876 let policy = MycPolicyContext::from_config(&config).expect("policy"); 877 let manager = in_memory_manager(); 878 let backend = backend_for(&manager); 879 let connection = register_connection(&manager, client_public_key); 880 881 manager 882 .require_auth_challenge(&connection.connection_id, "https://auth.example") 883 .expect("require auth challenge"); 884 manager 885 .authorize_auth_challenge(&connection.connection_id) 886 .expect("authorize auth challenge"); 887 thread::sleep(Duration::from_secs(2)); 888 889 let connection = manager 890 .get_connection(&connection.connection_id) 891 .expect("connection lookup") 892 .expect("connection"); 893 let denied = policy 894 .prepare_request( 895 &backend, 896 &connection, 897 &RadrootsNostrConnectRequestMessage::new( 898 "request-1", 899 RadrootsNostrConnectRequest::SignEvent(unsigned_event(1)), 900 ), 901 ) 902 .expect("prepare request"); 903 904 assert_eq!(denied, None); 905 let updated_connection = manager 906 .get_connection(&connection.connection_id) 907 .expect("connection lookup") 908 .expect("connection"); 909 assert_eq!( 910 updated_connection.auth_state, 911 RadrootsNostrSignerAuthState::Pending 912 ); 913 assert_eq!( 914 updated_connection 915 .auth_challenge 916 .expect("auth challenge") 917 .auth_url, 918 "https://auth.example/" 919 ); 920 } 921 922 #[test] 923 fn prepare_request_requires_fresh_auth_after_inactivity() { 924 let client_public_key = 925 public_key("2323232323232323232323232323232323232323232323232323232323232323"); 926 let mut config = MycPolicyConfig::default(); 927 config.trusted_client_pubkeys = vec![client_public_key.to_hex()]; 928 config.auth_url = Some("https://auth.example".to_owned()); 929 config.reauth_after_inactivity_secs = Some(1); 930 let policy = MycPolicyContext::from_config(&config).expect("policy"); 931 let manager = in_memory_manager(); 932 let backend = backend_for(&manager); 933 let connection = register_connection(&manager, client_public_key); 934 935 manager 936 .require_auth_challenge(&connection.connection_id, "https://auth.example") 937 .expect("require auth challenge"); 938 manager 939 .authorize_auth_challenge(&connection.connection_id) 940 .expect("authorize auth challenge"); 941 manager 942 .record_request( 943 &connection.connection_id, 944 "request-0", 945 RadrootsNostrConnectMethod::SignEvent, 946 radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Allowed, 947 None, 948 ) 949 .expect("record request"); 950 thread::sleep(Duration::from_secs(2)); 951 952 let connection = manager 953 .get_connection(&connection.connection_id) 954 .expect("connection lookup") 955 .expect("connection"); 956 let denied = policy 957 .prepare_request( 958 &backend, 959 &connection, 960 &RadrootsNostrConnectRequestMessage::new( 961 "request-1", 962 RadrootsNostrConnectRequest::SignEvent(unsigned_event(1)), 963 ), 964 ) 965 .expect("prepare request"); 966 967 assert_eq!(denied, None); 968 let updated_connection = manager 969 .get_connection(&connection.connection_id) 970 .expect("connection lookup") 971 .expect("connection"); 972 assert_eq!( 973 updated_connection.auth_state, 974 RadrootsNostrSignerAuthState::Pending 975 ); 976 } 977 }