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:
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(