control.rs (32226B)
1 use std::str::FromStr; 2 3 use radroots_nostr_connect::prelude::{ 4 RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest, 5 RadrootsNostrConnectResponse, RadrootsNostrConnectUri, 6 }; 7 use radroots_nostr_signer::prelude::{ 8 RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerBackend, 9 RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionRecord, 10 RadrootsNostrSignerPublishTransition, RadrootsNostrSignerPublishWorkflowRecord, 11 RadrootsNostrSignerRequestId, RadrootsNostrSignerWorkflowId, 12 }; 13 use serde::Serialize; 14 15 use crate::app::MycRuntime; 16 use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; 17 use crate::error::MycError; 18 use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord}; 19 use crate::transport::{MycNip46Handler, MycNostrTransport, MycPublishOutcome}; 20 21 #[derive(Debug, Serialize)] 22 pub struct MycAuthorizedReplayOutput { 23 pub connection: RadrootsNostrSignerConnectionRecord, 24 pub replayed_request_id: Option<String>, 25 } 26 27 #[derive(Debug, Serialize)] 28 pub struct MycAcceptedConnectionOutput { 29 pub connection: RadrootsNostrSignerConnectionRecord, 30 pub response_request_id: String, 31 pub response_relays: Vec<String>, 32 } 33 34 pub async fn authorize_auth_challenge( 35 runtime: &MycRuntime, 36 connection_id: &RadrootsNostrSignerConnectionId, 37 ) -> Result<MycAuthorizedReplayOutput, MycError> { 38 let backend = runtime.signer_backend(); 39 let connection = backend.get_connection(connection_id)?.ok_or_else(|| { 40 MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) 41 })?; 42 runtime 43 .signer_context() 44 .policy() 45 .ensure_authorize_auth_challenge_allowed(&connection)?; 46 let workflow = workflow_from_transition( 47 backend.begin_auth_replay_publish_finalization(connection_id)?, 48 "auth replay", 49 )?; 50 let replayed_request_id = 51 replay_authorized_request(runtime, &connection.connection_id, &workflow.workflow_id) 52 .await?; 53 let connection = runtime 54 .signer_backend() 55 .get_connection(connection_id)? 56 .ok_or_else(|| { 57 MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) 58 })?; 59 Ok(MycAuthorizedReplayOutput { 60 connection, 61 replayed_request_id, 62 }) 63 } 64 65 pub async fn accept_client_uri( 66 runtime: &MycRuntime, 67 uri: &str, 68 ) -> Result<MycAcceptedConnectionOutput, MycError> { 69 let Some(transport) = runtime.transport() else { 70 return Err(MycError::InvalidOperation( 71 "transport.enabled must be true to accept client nostrconnect URIs".to_owned(), 72 )); 73 }; 74 let preferred_relays = transport.relays().to_vec(); 75 if preferred_relays.is_empty() { 76 return Err(MycError::InvalidOperation( 77 "transport.relays must not be empty to accept client nostrconnect URIs".to_owned(), 78 )); 79 } 80 81 let client_uri = match RadrootsNostrConnectUri::parse(uri)? { 82 RadrootsNostrConnectUri::Client(client_uri) => client_uri, 83 RadrootsNostrConnectUri::Bunker(_) => { 84 return Err(MycError::InvalidOperation( 85 "connect accept requires a nostrconnect:// client URI".to_owned(), 86 )); 87 } 88 }; 89 90 let request = RadrootsNostrConnectRequest::Connect { 91 remote_signer_public_key: runtime.signer_identity().public_key(), 92 secret: Some(client_uri.secret.clone()), 93 requested_permissions: client_uri.metadata.requested_permissions.clone(), 94 }; 95 let backend = runtime.signer_backend(); 96 let Some(approval_requirement) = runtime 97 .signer_context() 98 .policy() 99 .approval_requirement_for_client(&client_uri.client_public_key) 100 else { 101 return Err(MycError::InvalidOperation( 102 "client public key denied by policy".to_owned(), 103 )); 104 }; 105 let connection = match backend.evaluate_connect_request(client_uri.client_public_key, request)? { 106 radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection( 107 connection, 108 ) => { 109 if connection.connect_secret_is_consumed() { 110 return Err(MycError::InvalidOperation( 111 "connect secret has already been consumed by a successful connection" 112 .to_owned(), 113 )); 114 } 115 if runtime 116 .signer_context() 117 .policy() 118 .approval_requirement_for_client(&connection.client_public_key) 119 .is_none() 120 { 121 return Err(MycError::InvalidOperation( 122 "client public key denied by policy".to_owned(), 123 )); 124 } 125 connection 126 } 127 radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired( 128 proposal, 129 ) => { 130 let requested_permissions = runtime 131 .signer_context() 132 .policy() 133 .filtered_requested_permissions(&proposal.requested_permissions); 134 let draft = proposal 135 .into_connection_draft(runtime.user_public_identity()) 136 .with_requested_permissions(requested_permissions) 137 .with_relays(preferred_relays.clone()) 138 .with_approval_requirement(approval_requirement); 139 let connection = backend.register_connection(draft)?; 140 if approval_requirement 141 == RadrootsNostrSignerApprovalRequirement::NotRequired 142 { 143 let granted_permissions = runtime 144 .signer_context() 145 .policy() 146 .auto_granted_permissions(&connection.requested_permissions); 147 let _ = backend.set_granted_permissions( 148 &connection.connection_id, 149 granted_permissions, 150 )?; 151 } 152 Box::new(connection) 153 } 154 }; 155 156 let handler = MycNip46Handler::new(runtime.signer_context(), preferred_relays.clone()); 157 let response_request_id = RadrootsNostrSignerRequestId::new_v7().into_string(); 158 let event = handler.build_response_event( 159 client_uri.client_public_key, 160 response_request_id.clone(), 161 RadrootsNostrConnectResponse::ConnectSecretEcho(client_uri.secret), 162 )?; 163 let response_relays = merge_relays(&client_uri.relays, &preferred_relays); 164 let workflow = workflow_from_transition( 165 backend.begin_connect_secret_publish_finalization(&connection.connection_id)?, 166 "connect accept", 167 )?; 168 let event = match runtime 169 .signer_identity() 170 .sign_event_builder(event, "connect accept response") 171 { 172 Ok(event) => event, 173 Err(error) => { 174 return Err(cancel_connect_accept_workflow_on_error( 175 runtime, 176 &workflow.workflow_id, 177 MycError::InvalidOperation(format!( 178 "failed to sign connect accept response event: {error}" 179 )), 180 )); 181 } 182 }; 183 let outbox_record = match build_control_outbox_record( 184 MycDeliveryOutboxKind::ConnectAcceptPublish, 185 event.clone(), 186 &response_relays, 187 Some(&connection.connection_id), 188 Some(response_request_id.as_str()), 189 Some(&workflow.workflow_id), 190 ) { 191 Ok(record) => record, 192 Err(error) => { 193 return Err(cancel_connect_accept_workflow_on_error( 194 runtime, 195 &workflow.workflow_id, 196 error, 197 )); 198 } 199 }; 200 if let Err(error) = runtime.delivery_outbox_store().enqueue(&outbox_record) { 201 return Err(cancel_connect_accept_workflow_on_error( 202 runtime, 203 &workflow.workflow_id, 204 error, 205 )); 206 } 207 let publish_outcome = match MycNostrTransport::publish_event_once( 208 runtime.signer_identity(), 209 &response_relays, 210 &runtime.config().transport, 211 "connect accept response publish", 212 &event, 213 ) 214 .await 215 { 216 Ok(outcome) => outcome, 217 Err(error) => { 218 let error = mark_outbox_publish_failed(runtime, &outbox_record, error); 219 runtime.record_operation_audit(&record_publish_failure( 220 MycOperationAuditKind::ConnectAcceptPublish, 221 Some(&connection.connection_id), 222 Some(response_request_id.as_str()), 223 response_relays.len(), 224 &error, 225 )); 226 return Err(cancel_connect_accept_workflow_on_error( 227 runtime, 228 &workflow.workflow_id, 229 error, 230 )); 231 } 232 }; 233 if let Err(error) = backend.mark_publish_workflow_published(&workflow.workflow_id) { 234 record_post_publish_failure( 235 runtime, 236 MycOperationAuditKind::ConnectAcceptPublish, 237 Some(&connection.connection_id), 238 Some(response_request_id.as_str()), 239 &publish_outcome, 240 format!("failed to mark connect-accept publish workflow as published: {error}"), 241 ); 242 return Err(error.into()); 243 } 244 if let Err(error) = runtime 245 .delivery_outbox_store() 246 .mark_published_pending_finalize(&outbox_record.job_id, publish_outcome.attempt_count) 247 { 248 record_post_publish_failure( 249 runtime, 250 MycOperationAuditKind::ConnectAcceptPublish, 251 Some(&connection.connection_id), 252 Some(response_request_id.as_str()), 253 &publish_outcome, 254 format!("failed to persist connect-accept outbox published state: {error}"), 255 ); 256 return Err(error); 257 } 258 if let Err(error) = backend.finalize_publish_workflow(&workflow.workflow_id) { 259 record_post_publish_failure( 260 runtime, 261 MycOperationAuditKind::ConnectAcceptPublish, 262 Some(&connection.connection_id), 263 Some(response_request_id.as_str()), 264 &publish_outcome, 265 format!("failed to finalize connect-accept publish workflow: {error}"), 266 ); 267 return Err(error.into()); 268 } 269 if let Err(error) = runtime 270 .delivery_outbox_store() 271 .mark_finalized(&outbox_record.job_id) 272 { 273 record_post_publish_failure( 274 runtime, 275 MycOperationAuditKind::ConnectAcceptPublish, 276 Some(&connection.connection_id), 277 Some(response_request_id.as_str()), 278 &publish_outcome, 279 format!("failed to finalize connect-accept outbox job: {error}"), 280 ); 281 return Err(error); 282 } 283 record_publish_audit( 284 runtime, 285 MycOperationAuditKind::ConnectAcceptPublish, 286 MycOperationAuditOutcome::Succeeded, 287 Some(&connection.connection_id), 288 Some(response_request_id.as_str()), 289 &publish_outcome, 290 ); 291 292 Ok(MycAcceptedConnectionOutput { 293 connection: backend 294 .get_connection(&connection.connection_id)? 295 .ok_or_else(|| { 296 MycError::InvalidOperation("accepted connection was not persisted".to_owned()) 297 })?, 298 response_request_id, 299 response_relays: response_relays.iter().map(ToString::to_string).collect(), 300 }) 301 } 302 303 pub fn parse_permission_values( 304 values: &[String], 305 ) -> Result<RadrootsNostrConnectPermissions, MycError> { 306 let mut permissions = Vec::new(); 307 for value in values { 308 for fragment in value.split(',') { 309 let trimmed = fragment.trim(); 310 if trimmed.is_empty() { 311 continue; 312 } 313 permissions.push(RadrootsNostrConnectPermission::from_str(trimmed)?); 314 } 315 } 316 permissions.sort(); 317 permissions.dedup(); 318 Ok(permissions.into()) 319 } 320 321 async fn replay_authorized_request( 322 runtime: &MycRuntime, 323 connection_id: &RadrootsNostrSignerConnectionId, 324 workflow_id: &RadrootsNostrSignerWorkflowId, 325 ) -> Result<Option<String>, MycError> { 326 let backend = runtime.signer_backend(); 327 let workflow = backend.get_publish_workflow(workflow_id)?.ok_or_else(|| { 328 MycError::InvalidOperation(format!("publish workflow `{workflow_id}` was not found")) 329 })?; 330 let Some(pending_request) = workflow.pending_request.clone() else { 331 return Ok(None); 332 }; 333 let transport = match runtime.transport() { 334 Some(transport) => transport, 335 None => { 336 let error = MycError::InvalidOperation( 337 "transport.enabled must be true to replay authorized requests".to_owned(), 338 ); 339 return Err(cancel_auth_replay_workflow_on_error( 340 runtime, 341 connection_id, 342 workflow_id, 343 Some(&pending_request.request_message.id), 344 error, 345 )); 346 } 347 }; 348 let handler = MycNip46Handler::new(runtime.signer_context(), transport.relays().to_vec()); 349 let evaluation = match backend.evaluate_auth_replay_publish_workflow(workflow_id) { 350 Ok(evaluation) => evaluation, 351 Err(error) => { 352 return Err(cancel_auth_replay_workflow_on_error( 353 runtime, 354 connection_id, 355 workflow_id, 356 Some(&pending_request.request_message.id), 357 error.into(), 358 )); 359 } 360 }; 361 let handled_outcome = match handler 362 .handle_authorized_request_evaluation(pending_request.request_message.clone(), evaluation) 363 { 364 Ok(handled_outcome) => handled_outcome, 365 Err(error) => { 366 return Err(cancel_auth_replay_workflow_on_error( 367 runtime, 368 connection_id, 369 workflow_id, 370 Some(&pending_request.request_message.id), 371 error, 372 )); 373 } 374 }; 375 if let Some(audit) = handled_outcome.audit.as_ref() { 376 runtime.signer_context().record_signer_request_audit(audit); 377 } 378 let Some((response, _, consume_connect_secret_for)) = 379 handled_outcome.handled_request.into_publish_parts() 380 else { 381 let error = MycError::InvalidOperation( 382 "authorized auth replay did not produce a response".to_owned(), 383 ); 384 return Err(cancel_auth_replay_workflow_on_error( 385 runtime, 386 connection_id, 387 workflow_id, 388 Some(&pending_request.request_message.id), 389 error, 390 )); 391 }; 392 if consume_connect_secret_for.is_some() { 393 return Err(cancel_auth_replay_workflow_on_error( 394 runtime, 395 connection_id, 396 workflow_id, 397 Some(&pending_request.request_message.id), 398 MycError::InvalidOperation( 399 "auth replay unexpectedly requested connect-secret finalization".to_owned(), 400 ), 401 )); 402 } 403 let event = match handler.build_response_event( 404 backend 405 .get_connection(connection_id)? 406 .ok_or_else(|| { 407 MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) 408 })? 409 .client_public_key, 410 pending_request.request_message.id.clone(), 411 response, 412 ) { 413 Ok(event) => event, 414 Err(error) => { 415 return Err(cancel_auth_replay_workflow_on_error( 416 runtime, 417 connection_id, 418 workflow_id, 419 Some(&pending_request.request_message.id), 420 error, 421 )); 422 } 423 }; 424 let connection = backend.get_connection(connection_id)?.ok_or_else(|| { 425 MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) 426 })?; 427 let event = match runtime 428 .signer_identity() 429 .sign_event_builder(event, "authorized auth replay response") 430 { 431 Ok(event) => event, 432 Err(error) => { 433 return Err(cancel_auth_replay_workflow_on_error( 434 runtime, 435 connection_id, 436 workflow_id, 437 Some(&pending_request.request_message.id), 438 MycError::InvalidOperation(format!( 439 "failed to sign authorized auth replay response event: {error}" 440 )), 441 )); 442 } 443 }; 444 let publish_relays = if connection.relays.is_empty() { 445 transport.relays().to_vec() 446 } else { 447 connection.relays.clone() 448 }; 449 let outbox_record = match build_control_outbox_record( 450 MycDeliveryOutboxKind::AuthReplayPublish, 451 event.clone(), 452 &publish_relays, 453 Some(connection_id), 454 Some(&pending_request.request_message.id), 455 Some(workflow_id), 456 ) { 457 Ok(record) => record, 458 Err(error) => { 459 return Err(cancel_auth_replay_workflow_on_error( 460 runtime, 461 connection_id, 462 workflow_id, 463 Some(&pending_request.request_message.id), 464 error, 465 )); 466 } 467 }; 468 if let Err(error) = runtime.delivery_outbox_store().enqueue(&outbox_record) { 469 return Err(cancel_auth_replay_workflow_on_error( 470 runtime, 471 connection_id, 472 workflow_id, 473 Some(&pending_request.request_message.id), 474 error, 475 )); 476 } 477 let publish_outcome = match MycNostrTransport::publish_event_once( 478 runtime.signer_identity(), 479 &publish_relays, 480 &runtime.config().transport, 481 "authorized auth replay publish", 482 &event, 483 ) 484 .await 485 { 486 Ok(publish_outcome) => publish_outcome, 487 Err(error) => { 488 let error = mark_outbox_publish_failed(runtime, &outbox_record, error); 489 runtime.record_operation_audit(&record_publish_failure( 490 MycOperationAuditKind::AuthReplayPublish, 491 Some(connection_id), 492 Some(pending_request.request_message.id.as_str()), 493 publish_relays.len(), 494 &error, 495 )); 496 return Err(cancel_auth_replay_workflow_on_error( 497 runtime, 498 connection_id, 499 workflow_id, 500 Some(&pending_request.request_message.id), 501 error, 502 )); 503 } 504 }; 505 if let Err(error) = backend.mark_publish_workflow_published(workflow_id) { 506 record_post_publish_failure( 507 runtime, 508 MycOperationAuditKind::AuthReplayPublish, 509 Some(connection_id), 510 Some(pending_request.request_message.id.as_str()), 511 &publish_outcome, 512 format!("failed to mark auth replay publish workflow as published: {error}"), 513 ); 514 return Err(error.into()); 515 } 516 if let Err(error) = runtime 517 .delivery_outbox_store() 518 .mark_published_pending_finalize(&outbox_record.job_id, publish_outcome.attempt_count) 519 { 520 record_post_publish_failure( 521 runtime, 522 MycOperationAuditKind::AuthReplayPublish, 523 Some(connection_id), 524 Some(pending_request.request_message.id.as_str()), 525 &publish_outcome, 526 format!("failed to persist auth replay outbox published state: {error}"), 527 ); 528 return Err(error); 529 } 530 if let Err(error) = backend.finalize_publish_workflow(workflow_id) { 531 record_post_publish_failure( 532 runtime, 533 MycOperationAuditKind::AuthReplayPublish, 534 Some(connection_id), 535 Some(pending_request.request_message.id.as_str()), 536 &publish_outcome, 537 format!("failed to finalize auth replay publish workflow: {error}"), 538 ); 539 return Err(error.into()); 540 } 541 if let Err(error) = runtime 542 .delivery_outbox_store() 543 .mark_finalized(&outbox_record.job_id) 544 { 545 record_post_publish_failure( 546 runtime, 547 MycOperationAuditKind::AuthReplayPublish, 548 Some(connection_id), 549 Some(pending_request.request_message.id.as_str()), 550 &publish_outcome, 551 format!("failed to finalize auth replay outbox job: {error}"), 552 ); 553 return Err(error); 554 } 555 record_publish_audit( 556 runtime, 557 MycOperationAuditKind::AuthReplayPublish, 558 MycOperationAuditOutcome::Succeeded, 559 Some(connection_id), 560 Some(pending_request.request_message.id.as_str()), 561 &publish_outcome, 562 ); 563 Ok(Some(pending_request.request_message.id.clone())) 564 } 565 566 fn cancel_auth_replay_workflow_on_error( 567 runtime: &MycRuntime, 568 connection_id: &RadrootsNostrSignerConnectionId, 569 workflow_id: &RadrootsNostrSignerWorkflowId, 570 request_id: Option<&str>, 571 error: MycError, 572 ) -> MycError { 573 let summary = publish_failure_summary(&error); 574 match runtime 575 .signer_backend() 576 .cancel_publish_workflow(workflow_id) 577 .map_err(MycError::from) 578 { 579 Ok(_) => { 580 let mut record = MycOperationAuditRecord::new( 581 MycOperationAuditKind::AuthReplayRestore, 582 MycOperationAuditOutcome::Restored, 583 Some(connection_id), 584 request_id, 585 error 586 .publish_rejection_counts() 587 .map(|(relay_count, _)| relay_count) 588 .unwrap_or_default(), 589 error 590 .publish_rejection_counts() 591 .map(|(_, acknowledged)| acknowledged) 592 .unwrap_or_default(), 593 format!("preserved pending auth challenge after replay failure: {summary}"), 594 ); 595 if let ( 596 Some(delivery_policy), 597 Some(required_acknowledged_relay_count), 598 Some(attempt_count), 599 ) = ( 600 error.publish_delivery_policy(), 601 error.publish_required_acknowledged_relay_count(), 602 error.publish_attempt_count(), 603 ) { 604 record = record.with_delivery_details( 605 delivery_policy, 606 required_acknowledged_relay_count, 607 attempt_count, 608 ); 609 } 610 runtime.record_operation_audit(&record); 611 error 612 } 613 Err(restore_error) => MycError::InvalidOperation(format!( 614 "{error}; additionally failed to cancel auth replay publish workflow: {restore_error}" 615 )), 616 } 617 } 618 619 fn cancel_connect_accept_workflow_on_error( 620 runtime: &MycRuntime, 621 workflow_id: &RadrootsNostrSignerWorkflowId, 622 error: MycError, 623 ) -> MycError { 624 match runtime 625 .signer_backend() 626 .cancel_publish_workflow(workflow_id) 627 .map(|_| ()) 628 .map_err(MycError::from) 629 { 630 Ok(()) => error, 631 Err(cancel_error) => MycError::InvalidOperation(format!( 632 "{error}; additionally failed to cancel connect-accept publish workflow: {cancel_error}" 633 )), 634 } 635 } 636 637 fn workflow_from_transition( 638 transition: RadrootsNostrSignerPublishTransition, 639 operation: &str, 640 ) -> Result<RadrootsNostrSignerPublishWorkflowRecord, MycError> { 641 transition.workflow().cloned().ok_or_else(|| { 642 MycError::InvalidOperation(format!( 643 "{operation} publish workflow did not return a workflow record" 644 )) 645 }) 646 } 647 648 fn build_control_outbox_record( 649 kind: MycDeliveryOutboxKind, 650 event: radroots_nostr::prelude::RadrootsNostrEvent, 651 relay_urls: &[nostr::RelayUrl], 652 connection_id: Option<&RadrootsNostrSignerConnectionId>, 653 request_id: Option<&str>, 654 workflow_id: Option<&RadrootsNostrSignerWorkflowId>, 655 ) -> Result<MycDeliveryOutboxRecord, MycError> { 656 let relay_urls = relay_urls.to_vec(); 657 let mut record = MycDeliveryOutboxRecord::new(kind, event, relay_urls)?; 658 if let Some(connection_id) = connection_id { 659 record = record.with_connection_id(connection_id); 660 } 661 if let Some(request_id) = request_id { 662 record = record.with_request_id(request_id.to_owned()); 663 } 664 if let Some(workflow_id) = workflow_id { 665 record = record.with_signer_publish_workflow_id(workflow_id); 666 } 667 Ok(record) 668 } 669 670 fn mark_outbox_publish_failed( 671 runtime: &MycRuntime, 672 outbox_record: &MycDeliveryOutboxRecord, 673 error: MycError, 674 ) -> MycError { 675 let publish_attempt_count = error.publish_attempt_count().unwrap_or_default(); 676 let summary = publish_failure_summary(&error); 677 match runtime.delivery_outbox_store().mark_failed( 678 &outbox_record.job_id, 679 publish_attempt_count, 680 &summary, 681 ) { 682 Ok(_) => error, 683 Err(outbox_error) => MycError::InvalidOperation(format!( 684 "{error}; additionally failed to persist publish failure to the delivery outbox: {outbox_error}" 685 )), 686 } 687 } 688 689 fn record_publish_audit( 690 runtime: &MycRuntime, 691 operation: MycOperationAuditKind, 692 outcome: MycOperationAuditOutcome, 693 connection_id: Option<&RadrootsNostrSignerConnectionId>, 694 request_id: Option<&str>, 695 publish_outcome: &MycPublishOutcome, 696 ) { 697 runtime.record_operation_audit( 698 &MycOperationAuditRecord::new( 699 operation, 700 outcome, 701 connection_id, 702 request_id, 703 publish_outcome.relay_count, 704 publish_outcome.acknowledged_relay_count, 705 publish_outcome.relay_outcome_summary.clone(), 706 ) 707 .with_delivery_details( 708 publish_outcome.delivery_policy, 709 publish_outcome.required_acknowledged_relay_count, 710 publish_outcome.attempt_count, 711 ), 712 ); 713 } 714 715 fn record_post_publish_failure( 716 runtime: &MycRuntime, 717 operation: MycOperationAuditKind, 718 connection_id: Option<&RadrootsNostrSignerConnectionId>, 719 request_id: Option<&str>, 720 publish_outcome: &MycPublishOutcome, 721 summary: impl Into<String>, 722 ) { 723 runtime.record_operation_audit( 724 &MycOperationAuditRecord::new( 725 operation, 726 MycOperationAuditOutcome::Rejected, 727 connection_id, 728 request_id, 729 publish_outcome.relay_count, 730 publish_outcome.acknowledged_relay_count, 731 summary.into(), 732 ) 733 .with_delivery_details( 734 publish_outcome.delivery_policy, 735 publish_outcome.required_acknowledged_relay_count, 736 publish_outcome.attempt_count, 737 ), 738 ); 739 } 740 741 fn publish_failure_summary(error: &MycError) -> String { 742 error 743 .publish_rejection_details() 744 .map(ToOwned::to_owned) 745 .unwrap_or_else(|| error.to_string()) 746 } 747 748 fn record_publish_failure( 749 operation: MycOperationAuditKind, 750 connection_id: Option<&RadrootsNostrSignerConnectionId>, 751 request_id: Option<&str>, 752 relay_count: usize, 753 error: &MycError, 754 ) -> MycOperationAuditRecord { 755 let mut record = MycOperationAuditRecord::new( 756 operation, 757 MycOperationAuditOutcome::Rejected, 758 connection_id, 759 request_id, 760 relay_count, 761 error 762 .publish_rejection_counts() 763 .map(|(_, acknowledged)| acknowledged) 764 .unwrap_or_default(), 765 publish_failure_summary(error), 766 ); 767 if let (Some(delivery_policy), Some(required_acknowledged_relay_count), Some(attempt_count)) = ( 768 error.publish_delivery_policy(), 769 error.publish_required_acknowledged_relay_count(), 770 error.publish_attempt_count(), 771 ) { 772 record = record.with_delivery_details( 773 delivery_policy, 774 required_acknowledged_relay_count, 775 attempt_count, 776 ); 777 } 778 record 779 } 780 781 fn merge_relays( 782 primary: &[nostr::RelayUrl], 783 secondary: &[nostr::RelayUrl], 784 ) -> Vec<nostr::RelayUrl> { 785 let mut relays = primary.to_vec(); 786 relays.extend_from_slice(secondary); 787 relays.sort_by(|left, right| left.as_str().cmp(right.as_str())); 788 relays.dedup_by(|left, right| left.as_str() == right.as_str()); 789 relays 790 } 791 792 #[cfg(test)] 793 mod tests { 794 use super::{accept_client_uri, authorize_auth_challenge}; 795 use crate::app::MycRuntime; 796 use crate::config::{MycConfig, MycConnectionApproval}; 797 use radroots_identity::RadrootsIdentity; 798 use radroots_nostr_connect::prelude::{ 799 RadrootsNostrConnectClientMetadata, RadrootsNostrConnectClientUri, RadrootsNostrConnectUri, 800 }; 801 use std::path::PathBuf; 802 use std::thread; 803 use std::time::Duration; 804 805 fn write_identity(path: &std::path::Path, secret_key: &str) { 806 let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); 807 crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); 808 } 809 810 fn runtime_with_config<F>(approval: MycConnectionApproval, configure: F) -> MycRuntime 811 where 812 F: FnOnce(&mut MycConfig), 813 { 814 let temp = tempfile::tempdir().expect("tempdir").keep(); 815 let mut config = MycConfig::default(); 816 config.paths.state_dir = PathBuf::from(&temp).join("state"); 817 config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); 818 config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); 819 config.policy.connection_approval = approval; 820 config.transport.enabled = true; 821 config.transport.relays = vec!["ws://127.0.0.1:65500".to_owned()]; 822 configure(&mut config); 823 write_identity( 824 &config.paths.signer_identity_path, 825 "1111111111111111111111111111111111111111111111111111111111111111", 826 ); 827 write_identity( 828 &config.paths.user_identity_path, 829 "2222222222222222222222222222222222222222222222222222222222222222", 830 ); 831 MycRuntime::bootstrap(config).expect("runtime") 832 } 833 834 #[tokio::test(flavor = "current_thread")] 835 async fn authorize_auth_challenge_rejects_expired_pending_challenge() { 836 let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { 837 config.policy.auth_pending_ttl_secs = 1; 838 }); 839 let manager = runtime.signer_manager().expect("manager"); 840 let connection = manager 841 .register_connection( 842 radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft::new( 843 nostr::Keys::generate().public_key(), 844 runtime.user_public_identity(), 845 ), 846 ) 847 .expect("register connection"); 848 manager 849 .require_auth_challenge(&connection.connection_id, "https://auth.example") 850 .expect("require auth challenge"); 851 852 thread::sleep(Duration::from_secs(2)); 853 854 let error = authorize_auth_challenge(&runtime, &connection.connection_id) 855 .await 856 .expect_err("expired auth challenge should be rejected"); 857 assert!(error.to_string().contains("auth challenge expired")); 858 } 859 860 #[tokio::test(flavor = "current_thread")] 861 async fn accept_client_uri_rejects_denied_client_pubkeys() { 862 let denied_identity = RadrootsIdentity::from_secret_key_str( 863 "3333333333333333333333333333333333333333333333333333333333333333", 864 ) 865 .expect("identity"); 866 let runtime = runtime_with_config(MycConnectionApproval::ExplicitUser, |config| { 867 config.policy.denied_client_pubkeys = vec![denied_identity.public_key().to_hex()]; 868 }); 869 let uri = RadrootsNostrConnectUri::Client(RadrootsNostrConnectClientUri { 870 client_public_key: denied_identity.public_key(), 871 relays: vec![nostr::RelayUrl::parse("ws://127.0.0.1:65500").expect("relay")], 872 secret: "client-secret".to_owned(), 873 metadata: RadrootsNostrConnectClientMetadata::default(), 874 }) 875 .to_string(); 876 877 let error = accept_client_uri(&runtime, &uri) 878 .await 879 .expect_err("denied client should be rejected"); 880 assert!( 881 error 882 .to_string() 883 .contains("client public key denied by policy") 884 ); 885 } 886 }