myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

commit b12ea6aadc3561d99bd131e89a373c693b888f8b
parent 081a0086eb9c15b96cf51d6f8c8c01ae1e1fbd8c
Author: triesap <tyson@radroots.org>
Date:   Fri, 17 Jul 2026 22:17:18 +0000

nip46: finalize active sessions on logout

- dispatch typed logout only for the owning active session
- publish the acknowledgement before applying revocation
- bypass fresh-auth challenges for the terminal request
- prove repeated requests fail until a new connection exists

Diffstat:
Msrc/policy.rs | 1+
Msrc/transport/nip46.rs | 156++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mtests/nip46_e2e.rs | 176++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 326 insertions(+), 7 deletions(-)

diff --git a/src/policy.rs b/src/policy.rs @@ -629,6 +629,7 @@ fn request_requires_auth(request: &RadrootsNostrConnectRequest) -> bool { | RadrootsNostrConnectRequest::GetPublicKey | RadrootsNostrConnectRequest::GetSessionCapability | RadrootsNostrConnectRequest::Ping + | RadrootsNostrConnectRequest::Logout ) } diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -10,15 +10,14 @@ use radroots_nostr_connect::prelude::{ RadrootsNostrConnectResponse, }; use radroots_nostr_signer::prelude::{ - RadrootsNostrSignerConnectionId, RadrootsNostrSignerHandledRequestOutcome, + RadrootsNostrSignerConnectionId, RadrootsNostrSignerConnectionStatus, + RadrootsNostrSignerHandledRequest, RadrootsNostrSignerHandledRequestOutcome, RadrootsNostrSignerNip46Handler, RadrootsNostrSignerNip46Signer, - RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerWorkflowId, + RadrootsNostrSignerRequestDecision, RadrootsNostrSignerRequestEvaluation, + RadrootsNostrSignerSessionLookup, RadrootsNostrSignerWorkflowId, }; use tokio::sync::broadcast; -#[cfg(test)] -use radroots_nostr_signer::prelude::RadrootsNostrSignerHandledRequest; - use crate::app::MycSignerContext; use crate::app::backend::MycSignerBackend; use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; @@ -191,11 +190,79 @@ impl MycNip46Handler { client_public_key: RadrootsNostrPublicKey, request_message: RadrootsNostrConnectRequestMessage, ) -> Result<MycNip46HandledOutcome, MycError> { + if matches!( + &request_message.request, + radroots_nostr_connect::prelude::RadrootsNostrConnectRequest::Logout + ) { + return self.handle_logout_request(client_public_key, request_message); + } self.handler .handle_request(client_public_key, request_message) .map_err(Into::into) } + fn handle_logout_request( + &self, + client_public_key: RadrootsNostrPublicKey, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<MycNip46HandledOutcome, MycError> { + let manager = self.signer.load_signer_manager()?; + let connection = match manager.lookup_session(&client_public_key, None)? { + RadrootsNostrSignerSessionLookup::Connection(connection) => *connection, + RadrootsNostrSignerSessionLookup::None => { + return Ok(MycNip46HandledOutcome::respond( + RadrootsNostrConnectResponse::Error { + result: None, + error: "unauthorized".to_owned(), + }, + )); + } + RadrootsNostrSignerSessionLookup::Ambiguous(_) => { + return Ok(MycNip46HandledOutcome::respond( + RadrootsNostrConnectResponse::Error { + result: None, + error: "ambiguous client sessions".to_owned(), + }, + )); + } + }; + if connection.status != RadrootsNostrSignerConnectionStatus::Active { + let reason = format!("connection is {:?}", connection.status).to_lowercase(); + let audit = manager.record_request( + &connection.connection_id, + &request_message.id, + request_message.request.method(), + RadrootsNostrSignerRequestDecision::Denied, + Some(reason.clone()), + )?; + return Ok(MycNip46HandledOutcome::new( + RadrootsNostrSignerHandledRequest::respond_for_connection( + Some(connection.connection_id), + RadrootsNostrConnectResponse::Error { + result: None, + error: reason, + }, + ), + Some(audit), + )); + } + + let audit = manager.record_request( + &connection.connection_id, + &request_message.id, + request_message.request.method(), + RadrootsNostrSignerRequestDecision::Allowed, + None, + )?; + Ok(MycNip46HandledOutcome::new( + RadrootsNostrSignerHandledRequest::respond_for_connection( + Some(connection.connection_id), + RadrootsNostrConnectResponse::LogoutAcknowledged, + ), + Some(audit), + )) + } + #[cfg(test)] fn handle_request_response( &self, @@ -314,6 +381,10 @@ impl MycNip46Service { ); continue; }; + let revoke_logout_connection = + matches!(&response, RadrootsNostrConnectResponse::LogoutAcknowledged) + .then(|| connection_id.clone()) + .flatten(); let response_event = self.handler @@ -469,6 +540,34 @@ impl MycNip46Service { continue; } } + if let Some(logout_connection_id) = revoke_logout_connection.as_ref() { + let manager = match self.handler.signer.load_signer_manager() { + Ok(manager) => manager, + Err(error) => { + self.record_listener_publish_post_publish_failure( + connection_id.as_ref(), + request_id.as_str(), + &publish_outcome, + format!( + "failed to load signer manager for logout finalization: {error}" + ), + ); + continue; + } + }; + if let Err(error) = manager.revoke_connection( + logout_connection_id, + Some("NIP-46 logout acknowledged".to_owned()), + ) { + self.record_listener_publish_post_publish_failure( + connection_id.as_ref(), + request_id.as_str(), + &publish_outcome, + format!("failed to finalize NIP-46 logout: {error}"), + ); + continue; + } + } if let Err(error) = self .delivery_outbox_store .mark_finalized(&outbox_record.job_id) @@ -683,7 +782,8 @@ mod tests { RadrootsNostrConnectResponseEnvelope, }; use radroots_nostr_signer::prelude::{ - RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerHandledRequest, + RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus, + RadrootsNostrSignerHandledRequest, }; use serde_json::json; @@ -1167,6 +1267,50 @@ mod tests { } #[test] + fn logout_requires_active_session_and_defers_revocation_until_publish() { + let pending_runtime = runtime_with_explicit_approval(); + let pending_handler = handler(&pending_runtime); + connect_with_permissions(&pending_handler, &pending_runtime, Vec::new()); + let pending_response = pending_handler + .handle_request_response( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-pending-logout", + RadrootsNostrConnectRequest::Logout, + ), + ) + .expect("pending logout response"); + assert_eq!( + pending_response, + RadrootsNostrConnectResponse::Error { + result: None, + error: "connection is pending".to_owned(), + } + ); + + let active_runtime = runtime(); + let active_handler = handler(&active_runtime); + connect_with_permissions(&active_handler, &active_runtime, Vec::new()); + let active_response = active_handler + .handle_request_response( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-active-logout", + RadrootsNostrConnectRequest::Logout, + ), + ) + .expect("active logout response"); + assert_eq!( + active_response, + RadrootsNostrConnectResponse::LogoutAcknowledged + ); + assert_eq!( + connection_for(&active_runtime, client_keys().public_key()).status, + RadrootsNostrSignerConnectionStatus::Active + ); + } + + #[test] fn trusted_clients_auto_grant_only_policy_allowed_permissions() { let trusted_client_keys = client_keys_from_hex( "4545454545454545454545454545454545454545454545454545454545454545", diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -39,7 +39,7 @@ use radroots_nostr_connect::prelude::{ }; use radroots_nostr_signer::prelude::{ RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerAuthState, - RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionStatus, }; use tempfile::TempDir; use tokio::net::{TcpListener, TcpStream}; @@ -786,6 +786,31 @@ async fn wait_for_connection_count(runtime: &MycRuntime, expected: usize) -> Tes Ok(()) } +async fn wait_for_client_connection_status( + runtime: &MycRuntime, + client_public_key: PublicKey, + expected: RadrootsNostrSignerConnectionStatus, +) -> TestResult<()> { + timeout(RUNTIME_STATE_TIMEOUT, async { + loop { + let matches = runtime + .signer_manager() + .expect("manager") + .find_connections_by_client_public_key(&client_public_key) + .expect("connections"); + if matches + .iter() + .any(|connection| connection.status == expected) + { + return; + } + sleep(POLL_INTERVAL).await; + } + }) + .await?; + Ok(()) +} + async fn wait_for_connect_secret_consumed(runtime: &MycRuntime) -> TestResult<()> { timeout(RUNTIME_STATE_TIMEOUT, async { loop { @@ -1480,6 +1505,155 @@ async fn live_listener_consumes_connect_secret_only_after_successful_publish() - } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn live_listener_acknowledges_logout_before_revoking_session() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = MycTestRuntime::new(relay.url(), MycConnectionApproval::NotRequired); + let runtime = test_runtime.runtime.clone(); + let signer_public_key = runtime.signer_identity().public_key(); + let client_identity = + identity("3636363636363636363636363636363636363636363636363636363636363636"); + let base_created_at = Timestamp::now().as_secs(); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let service_runtime = runtime.clone(); + let listener_task = tokio::spawn(async move { + service_runtime + .run_until(async { + let _ = shutdown_rx.await; + }) + .await + }); + relay.wait_for_subscription_count(1).await?; + + let connect = build_request_event( + &client_identity, + signer_public_key, + connect_request_message("logout-connect", signer_public_key, "logout-secret"), + base_created_at, + ); + publish_event(relay.url(), &connect).await?; + let responses = relay + .wait_for_published_events_by_author(signer_public_key, 1) + .await?; + let connect_response = decrypt_response(&client_identity, signer_public_key, &responses[0]); + assert_eq!(connect_response.id, "logout-connect"); + + let logout = build_request_event( + &client_identity, + signer_public_key, + RadrootsNostrConnectRequestMessage::new( + "logout-request", + RadrootsNostrConnectRequest::Logout, + ), + base_created_at + 1, + ); + publish_event(relay.url(), &logout).await?; + let responses = relay + .wait_for_published_events_by_author(signer_public_key, 2) + .await?; + let logout_response = decrypt_response(&client_identity, signer_public_key, &responses[1]); + let logout_response = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::Logout.method(), + logout_response, + )?; + assert_eq!( + logout_response, + RadrootsNostrConnectResponse::LogoutAcknowledged + ); + wait_for_client_connection_status( + &runtime, + client_identity.public_key(), + RadrootsNostrSignerConnectionStatus::Revoked, + ) + .await?; + + let repeated_logout = build_request_event( + &client_identity, + signer_public_key, + RadrootsNostrConnectRequestMessage::new( + "repeated-logout", + RadrootsNostrConnectRequest::Logout, + ), + base_created_at + 2, + ); + publish_event(relay.url(), &repeated_logout).await?; + let responses = relay + .wait_for_published_events_by_author(signer_public_key, 3) + .await?; + let repeated_logout_response = + decrypt_response(&client_identity, signer_public_key, &responses[2]); + let repeated_logout_response = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::Logout.method(), + repeated_logout_response, + )?; + assert_eq!( + repeated_logout_response, + RadrootsNostrConnectResponse::Error { + result: None, + error: "unauthorized".to_owned(), + } + ); + + let ping = build_request_event( + &client_identity, + signer_public_key, + ping_request_message("post-logout-ping"), + base_created_at + 3, + ); + publish_event(relay.url(), &ping).await?; + let responses = relay + .wait_for_published_events_by_author(signer_public_key, 4) + .await?; + let ping_response = decrypt_response(&client_identity, signer_public_key, &responses[3]); + let ping_response = RadrootsNostrConnectResponse::from_envelope( + &RadrootsNostrConnectRequest::Ping.method(), + ping_response, + )?; + assert_eq!( + ping_response, + RadrootsNostrConnectResponse::Error { + result: None, + error: "unauthorized".to_owned(), + } + ); + + let reconnect = build_request_event( + &client_identity, + signer_public_key, + connect_request_message("logout-reconnect", signer_public_key, "new-logout-secret"), + base_created_at + 4, + ); + publish_event(relay.url(), &reconnect).await?; + let responses = relay + .wait_for_published_events_by_author(signer_public_key, 5) + .await?; + let reconnect_response = decrypt_response(&client_identity, signer_public_key, &responses[4]); + assert_eq!(reconnect_response.id, "logout-reconnect"); + let connections = runtime + .signer_manager()? + .find_connections_by_client_public_key(&client_identity.public_key())?; + assert_eq!(connections.len(), 2); + assert_eq!( + connections + .iter() + .filter(|connection| connection.status == RadrootsNostrSignerConnectionStatus::Revoked) + .count(), + 1 + ); + assert_eq!( + connections + .iter() + .filter(|connection| connection.status == RadrootsNostrSignerConnectionStatus::Active) + .count(), + 1 + ); + + let _ = shutdown_tx.send(()); + listener_task.await??; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn live_listener_works_with_sqlite_signer_state_and_runtime_audit() -> TestResult<()> { let relay = TestRelay::spawn().await?; let test_runtime = MycTestRuntime::new_with_transport_config(