myc

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

commit 78eba8ad069b8012d1b40286083548a3899a1bc3
parent 4b72b37d429104cffa4e32aa196ceb97f8a38ff5
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 00:34:38 +0000

transport: ignore consumed connect secrets

- add typed NIP-46 request outcomes so consumed secret replays can be ignored without publishing a response
- persist one-shot connect secret consumption only after successful response publish so failed publishes can retry safely
- apply the same consumed secret semantics to auth replay and client nostrconnect acceptance flows
- validate with cargo fmt --check, cargo test, cargo check --locked, and cargo test --locked

Diffstat:
Msrc/cli.rs | 58+++++++++++++++++++++++++++++++++++++---------------------
Msrc/transport/nip46.rs | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
2 files changed, 300 insertions(+), 85 deletions(-)

diff --git a/src/cli.rs b/src/cli.rs @@ -265,10 +265,13 @@ async fn replay_authorized_request( ) })?; let handler = MycNip46Handler::new(runtime.signer_context(), transport.relays().to_vec()); - let response = handler.handle_request( + let handled_request = handler.handle_request( outcome.connection.client_public_key, pending_request.request_message.clone(), )?; + let Some((response, consume_connect_secret_for)) = handled_request.into_publish_parts() else { + return Ok(None); + }; let event = handler.build_response_event( outcome.connection.client_public_key, pending_request.request_message.id.clone(), @@ -286,6 +289,11 @@ async fn replay_authorized_request( event, ) .await?; + if let Some(connection_id) = consume_connect_secret_for { + runtime + .signer_manager()? + .mark_connect_secret_consumed(&connection_id)?; + } Ok(Some(pending_request.request_message.id.clone())) } @@ -320,31 +328,38 @@ async fn accept_client_uri( requested_permissions: client_uri.metadata.requested_permissions.clone(), }; let manager = runtime.signer_manager()?; - let proposal = match manager.evaluate_connect_request(client_uri.client_public_key, request)? { - radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection(_) => { - return Err(MycError::InvalidOperation( - "connect secret is already bound to an existing connection".to_owned(), - )); + let connection = match manager.evaluate_connect_request(client_uri.client_public_key, request)? { + radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::ExistingConnection( + connection, + ) => { + if connection.connect_secret_is_consumed() { + return Err(MycError::InvalidOperation( + "connect secret has already been consumed by a successful connection" + .to_owned(), + )); + } + connection } radroots_nostr_signer::prelude::RadrootsNostrSignerConnectEvaluation::RegistrationRequired( proposal, - ) => proposal, + ) => { + let draft = proposal + .into_connection_draft(runtime.user_public_identity()) + .with_relays(preferred_relays.clone()) + .with_approval_requirement(runtime.signer_context().connection_approval_requirement()); + let connection = manager.register_connection(draft)?; + if runtime.signer_context().connection_approval_requirement() + == RadrootsNostrSignerApprovalRequirement::NotRequired + { + let _ = manager.set_granted_permissions( + &connection.connection_id, + connection.requested_permissions.clone(), + )?; + } + connection + } }; - let draft = proposal - .into_connection_draft(runtime.user_public_identity()) - .with_relays(preferred_relays.clone()) - .with_approval_requirement(runtime.signer_context().connection_approval_requirement()); - let connection = manager.register_connection(draft)?; - if runtime.signer_context().connection_approval_requirement() - == RadrootsNostrSignerApprovalRequirement::NotRequired - { - let _ = manager.set_granted_permissions( - &connection.connection_id, - connection.requested_permissions.clone(), - )?; - } - let handler = MycNip46Handler::new(runtime.signer_context(), preferred_relays.clone()); let response_request_id = RadrootsNostrSignerRequestId::new_v7().into_string(); let event = handler.build_response_event( @@ -360,6 +375,7 @@ async fn accept_client_uri( event, ) .await?; + let _ = manager.mark_connect_secret_consumed(&connection.connection_id)?; Ok(MycAcceptedConnectionOutput { connection: runtime diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs @@ -11,9 +11,9 @@ use radroots_nostr_connect::prelude::{ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, }; use radroots_nostr_signer::prelude::{ - RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectionRecord, - RadrootsNostrSignerRequestAction, RadrootsNostrSignerRequestResponseHint, - RadrootsNostrSignerSessionLookup, + RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectionId, + RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAction, + RadrootsNostrSignerRequestResponseHint, RadrootsNostrSignerSessionLookup, }; use tokio::sync::broadcast; @@ -32,6 +32,15 @@ pub struct MycNip46Service { transport: MycNostrTransport, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MycNip46HandledRequest { + Respond { + response: RadrootsNostrConnectResponse, + consume_connect_secret_for: Option<RadrootsNostrSignerConnectionId>, + }, + Ignore, +} + impl MycNip46Handler { pub fn new(signer: MycSignerContext, relays: Vec<RadrootsNostrRelayUrl>) -> Self { Self { signer, relays } @@ -88,11 +97,11 @@ impl MycNip46Handler { .tags(vec![RadrootsNostrTag::public_key(client_public_key)])) } - pub fn handle_request( + pub(crate) fn handle_request( &self, client_public_key: RadrootsNostrPublicKey, request_message: RadrootsNostrConnectRequestMessage, - ) -> Result<RadrootsNostrConnectResponse, MycError> { + ) -> Result<MycNip46HandledRequest, MycError> { match request_message.request.clone() { RadrootsNostrConnectRequest::Connect { secret, .. } => { self.handle_connect_request(client_public_key, request_message.request, secret) @@ -111,13 +120,29 @@ impl MycNip46Handler { | RadrootsNostrConnectRequest::SwitchRelays => { self.handle_base_request(client_public_key, request_message) } - _ => Ok(RadrootsNostrConnectResponse::Error { - result: None, - error: format!( - "method `{}` is not implemented yet", - request_message.request.method() - ), - }), + _ => Ok(MycNip46HandledRequest::respond( + RadrootsNostrConnectResponse::Error { + result: None, + error: format!( + "method `{}` is not implemented yet", + request_message.request.method() + ), + }, + )), + } + } + + #[cfg(test)] + fn handle_request_response( + &self, + client_public_key: RadrootsNostrPublicKey, + request_message: RadrootsNostrConnectRequestMessage, + ) -> Result<RadrootsNostrConnectResponse, MycError> { + match self.handle_request(client_public_key, request_message)? { + MycNip46HandledRequest::Respond { response, .. } => Ok(response), + MycNip46HandledRequest::Ignore => Err(MycError::InvalidOperation( + "request was ignored without a response".to_owned(), + )), } } @@ -126,13 +151,20 @@ impl MycNip46Handler { client_public_key: RadrootsNostrPublicKey, request: RadrootsNostrConnectRequest, secret: Option<String>, - ) -> Result<RadrootsNostrConnectResponse, MycError> { + ) -> Result<MycNip46HandledRequest, MycError> { let manager = self.signer.load_signer_manager()?; let evaluation = manager.evaluate_connect_request(client_public_key, request)?; match evaluation { - RadrootsNostrSignerConnectEvaluation::ExistingConnection(_) => { - Ok(connect_response(secret)) + RadrootsNostrSignerConnectEvaluation::ExistingConnection(connection) => { + if secret.is_some() && connection.connect_secret_is_consumed() { + tracing::debug!( + connection_id = %connection.connection_id, + "ignoring reused consumed NIP-46 connect secret" + ); + return Ok(MycNip46HandledRequest::Ignore); + } + Ok(connect_response_outcome(&connection, secret)) } RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => { let draft = proposal @@ -150,7 +182,7 @@ impl MycNip46Handler { granted_permissions, )?; } - Ok(connect_response(secret)) + Ok(connect_response_outcome(&connection, secret)) } } } @@ -159,27 +191,30 @@ impl MycNip46Handler { &self, client_public_key: RadrootsNostrPublicKey, request_message: RadrootsNostrConnectRequestMessage, - ) -> Result<RadrootsNostrConnectResponse, MycError> { + ) -> Result<MycNip46HandledRequest, MycError> { let connection = match self.lookup_connection(client_public_key)? { Ok(connection) => connection, - Err(response) => return Ok(response), + Err(response) => return Ok(MycNip46HandledRequest::respond(response)), }; let manager = self.signer.load_signer_manager()?; let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => { - Ok(RadrootsNostrConnectResponse::Error { + RadrootsNostrSignerRequestAction::Denied { reason } => Ok( + MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error { result: None, error: reason, - }) - } - RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => Ok( - RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + }), ), + RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { + Ok(MycNip46HandledRequest::respond( + RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + )) + } RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => { response_from_hint(&evaluation.connection, response_hint) + .map(MycNip46HandledRequest::respond) } } } @@ -189,28 +224,30 @@ impl MycNip46Handler { client_public_key: RadrootsNostrPublicKey, request_message: RadrootsNostrConnectRequestMessage, unsigned_event: nostr::UnsignedEvent, - ) -> Result<RadrootsNostrConnectResponse, MycError> { + ) -> Result<MycNip46HandledRequest, MycError> { let connection = match self.lookup_connection(client_public_key)? { Ok(connection) => connection, - Err(response) => return Ok(response), + Err(response) => return Ok(MycNip46HandledRequest::respond(response)), }; let manager = self.signer.load_signer_manager()?; let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => { - Ok(RadrootsNostrConnectResponse::Error { + RadrootsNostrSignerRequestAction::Denied { reason } => Ok( + MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error { result: None, error: reason, - }) - } - RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => Ok( - RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + }), ), - RadrootsNostrSignerRequestAction::Allowed { .. } => { - self.sign_event_response(unsigned_event) + RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { + Ok(MycNip46HandledRequest::respond( + RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + )) } + RadrootsNostrSignerRequestAction::Allowed { .. } => self + .sign_event_response(unsigned_event) + .map(MycNip46HandledRequest::respond), } } @@ -218,27 +255,31 @@ impl MycNip46Handler { &self, client_public_key: RadrootsNostrPublicKey, request_message: RadrootsNostrConnectRequestMessage, - ) -> Result<RadrootsNostrConnectResponse, MycError> { + ) -> Result<MycNip46HandledRequest, MycError> { let request = request_message.request.clone(); let connection = match self.lookup_connection(client_public_key)? { Ok(connection) => connection, - Err(response) => return Ok(response), + Err(response) => return Ok(MycNip46HandledRequest::respond(response)), }; let manager = self.signer.load_signer_manager()?; let evaluation = manager.evaluate_request(&connection.connection_id, request_message)?; match evaluation.action { - RadrootsNostrSignerRequestAction::Denied { reason } => { - Ok(RadrootsNostrConnectResponse::Error { + RadrootsNostrSignerRequestAction::Denied { reason } => Ok( + MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error { result: None, error: reason, - }) - } - RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => Ok( - RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + }), ), - RadrootsNostrSignerRequestAction::Allowed { .. } => self.crypto_response(request), + RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => { + Ok(MycNip46HandledRequest::respond( + RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url), + )) + } + RadrootsNostrSignerRequestAction::Allowed { .. } => self + .crypto_response(request) + .map(MycNip46HandledRequest::respond), } } @@ -388,16 +429,25 @@ impl MycNip46Service { }; let request_id = request_message.id.clone(); - let response = match self.handler.handle_request(event.pubkey, request_message) { - Ok(response) => response, + let handled_request = match self.handler.handle_request(event.pubkey, request_message) { + Ok(handled_request) => handled_request, Err(error) => { tracing::warn!(error = %error, "failed to handle NIP-46 request"); - RadrootsNostrConnectResponse::Error { + MycNip46HandledRequest::respond(RadrootsNostrConnectResponse::Error { result: None, error: error.to_string(), - } + }) } }; + let Some((response, consume_connect_secret_for)) = handled_request.into_publish_parts() + else { + tracing::debug!( + request_id = %request_id, + client_public_key = %event.pubkey, + "ignoring NIP-46 request without response" + ); + continue; + }; let response_event = self.handler @@ -409,11 +459,50 @@ impl MycNip46Service { .await { tracing::warn!(error = %error, "failed to publish NIP-46 response"); + continue; + } + if let Some(connection_id) = consume_connect_secret_for { + if let Err(error) = self + .handler + .signer + .load_signer_manager()? + .mark_connect_secret_consumed(&connection_id) + { + tracing::warn!( + error = %error, + connection_id = %connection_id, + "failed to persist consumed NIP-46 connect secret" + ); + } } } } } +impl MycNip46HandledRequest { + fn respond(response: RadrootsNostrConnectResponse) -> Self { + Self::Respond { + response, + consume_connect_secret_for: None, + } + } + + pub(crate) fn into_publish_parts( + self, + ) -> Option<( + RadrootsNostrConnectResponse, + Option<RadrootsNostrSignerConnectionId>, + )> { + match self { + Self::Respond { + response, + consume_connect_secret_for, + } => Some((response, consume_connect_secret_for)), + Self::Ignore => None, + } + } +} + fn connect_response(secret: Option<String>) -> RadrootsNostrConnectResponse { match secret { Some(secret) => RadrootsNostrConnectResponse::ConnectSecretEcho(secret), @@ -421,6 +510,17 @@ fn connect_response(secret: Option<String>) -> RadrootsNostrConnectResponse { } } +fn connect_response_outcome( + connection: &RadrootsNostrSignerConnectionRecord, + secret: Option<String>, +) -> MycNip46HandledRequest { + let consume_connect_secret_for = secret.as_ref().map(|_| connection.connection_id.clone()); + MycNip46HandledRequest::Respond { + response: connect_response(secret), + consume_connect_secret_for, + } +} + fn grant_permissions_for_new_connection( requested_permissions: RadrootsNostrConnectPermissions, ) -> RadrootsNostrConnectPermissions { @@ -471,7 +571,7 @@ mod tests { use crate::app::MycRuntime; use crate::config::{MycConfig, MycConnectionApproval}; - use super::MycNip46Handler; + use super::{MycNip46HandledRequest, MycNip46Handler}; fn write_identity(path: &std::path::Path, secret_key: &str) { radroots_identity::RadrootsIdentity::from_secret_key_str(secret_key) @@ -591,7 +691,7 @@ mod tests { requested_permissions: Vec<RadrootsNostrConnectPermission>, ) { handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-connect", @@ -643,7 +743,7 @@ mod tests { let runtime = runtime(); let handler = handler(&runtime); let response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-connect", @@ -674,12 +774,111 @@ mod tests { } #[test] + fn existing_unconsumed_connect_secret_can_still_retry_after_failed_publish() { + let runtime = runtime(); + let handler = handler(&runtime); + + let first = handler + .handle_request_response( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect-1", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: Some("s3cr3t".to_owned()), + requested_permissions: Default::default(), + }, + ), + ) + .expect("first connect response"); + let second = handler + .handle_request_response( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect-2", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: Some("s3cr3t".to_owned()), + requested_permissions: Default::default(), + }, + ), + ) + .expect("second connect response"); + + assert_eq!( + first, + RadrootsNostrConnectResponse::ConnectSecretEcho("s3cr3t".to_owned()) + ); + assert_eq!(second, first); + } + + #[test] + fn consumed_connect_secret_is_ignored_on_reuse() { + let runtime = runtime(); + let handler = handler(&runtime); + let response = handler + .handle_request_response( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: Some("s3cr3t".to_owned()), + requested_permissions: Default::default(), + }, + ), + ) + .expect("connect response"); + assert_eq!( + response, + RadrootsNostrConnectResponse::ConnectSecretEcho("s3cr3t".to_owned()) + ); + + let connection = runtime + .signer_manager() + .expect("manager") + .list_connections() + .expect("connections") + .into_iter() + .next() + .expect("connection"); + runtime + .signer_manager() + .expect("manager") + .mark_connect_secret_consumed(&connection.connection_id) + .expect("consume connect secret"); + + let ignored = handler + .handle_request( + client_keys().public_key(), + RadrootsNostrConnectRequestMessage::new( + "req-connect-reused", + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: runtime.signer_identity().public_key(), + secret: Some("s3cr3t".to_owned()), + requested_permissions: Default::default(), + }, + ), + ) + .expect("ignored response"); + + assert_eq!(ignored, MycNip46HandledRequest::Ignore); + let connections = runtime + .signer_manager() + .expect("manager") + .list_connections() + .expect("connections"); + assert_eq!(connections.len(), 1); + assert!(connections[0].connect_secret_is_consumed()); + } + + #[test] fn connect_preserves_pending_status_when_explicit_approval_is_required() { let runtime = runtime_with_explicit_approval(); let handler = handler(&runtime); let response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-connect", @@ -717,7 +916,7 @@ mod tests { let runtime = runtime(); let handler = handler(&runtime); handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-connect", @@ -734,7 +933,7 @@ mod tests { .expect("connect"); let public_key = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-pubkey", @@ -748,7 +947,7 @@ mod tests { ); let pong = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-ping", @@ -759,7 +958,7 @@ mod tests { assert_eq!(pong, RadrootsNostrConnectResponse::Pong); let relays = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-switch", @@ -780,7 +979,7 @@ mod tests { let runtime = runtime(); let handler = handler(&runtime); handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-connect", @@ -814,7 +1013,7 @@ mod tests { connect_with_permissions(&handler, &runtime, vec![sign_event_permission(1)]); let response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-sign", @@ -843,7 +1042,7 @@ mod tests { connect_with_permissions(&handler, &runtime, Vec::new()); let response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-sign", @@ -872,7 +1071,7 @@ mod tests { connect_with_permissions(&handler, &runtime, vec![sign_event_permission(1)]); let response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-sign", @@ -908,7 +1107,7 @@ mod tests { ); let encrypt_response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-nip04-encrypt", @@ -939,7 +1138,7 @@ mod tests { ) .expect("client encrypt"); let decrypt_response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-nip04-decrypt", @@ -970,7 +1169,7 @@ mod tests { ); let encrypt_response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-nip44-encrypt", @@ -1002,7 +1201,7 @@ mod tests { ) .expect("client encrypt"); let decrypt_response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-nip44-decrypt", @@ -1032,7 +1231,7 @@ mod tests { ); let response = handler - .handle_request( + .handle_request_response( client_keys().public_key(), RadrootsNostrConnectRequestMessage::new( "req-nip04-decrypt",