commit 8c02a5e2b37472942b71c2ee92d1e8eb6adde486
parent f7ed72face51e5be82bf2c49c61a41a95472b861
Author: triesap <tyson@radroots.org>
Date: Sat, 21 Mar 2026 22:33:44 +0000
transport: implement sign_event handling
- route sign_event through session lookup and rr-rs request evaluation
- sign allowed unsigned events with the managed user identity and reject pubkey mismatches
- preserve requested connection permissions instead of implicitly expanding switch_relays grants
- validate with cargo fmt, cargo check --locked, cargo test --locked, and cargo fmt --check
Diffstat:
1 file changed, 238 insertions(+), 29 deletions(-)
diff --git a/src/transport/nip46.rs b/src/transport/nip46.rs
@@ -6,8 +6,7 @@ use radroots_nostr::prelude::{
RadrootsNostrTag, RadrootsNostrTimestamp, radroots_nostr_filter_tag, radroots_nostr_kind,
};
use radroots_nostr_connect::prelude::{
- RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectMethod, RadrootsNostrConnectPermission,
- RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
};
use radroots_nostr_signer::prelude::{
@@ -97,6 +96,9 @@ impl MycNip46Handler {
RadrootsNostrConnectRequest::Connect { secret, .. } => {
self.handle_connect_request(client_public_key, request_message.request, secret)
}
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event) => {
+ self.handle_sign_event_request(client_public_key, request_message, unsigned_event)
+ }
RadrootsNostrConnectRequest::GetPublicKey
| RadrootsNostrConnectRequest::Ping
| RadrootsNostrConnectRequest::SwitchRelays => {
@@ -148,24 +150,41 @@ impl MycNip46Handler {
client_public_key: RadrootsNostrPublicKey,
request_message: RadrootsNostrConnectRequestMessage,
) -> Result<RadrootsNostrConnectResponse, MycError> {
- let connection = match self
+ let connection = match self.lookup_connection(client_public_key)? {
+ Ok(connection) => connection,
+ Err(response) => return Ok(response),
+ };
+
+ let evaluation = self
.signer
.signer_manager()
- .lookup_session(&client_public_key, None)?
- {
- RadrootsNostrSignerSessionLookup::Connection(connection) => connection,
- RadrootsNostrSignerSessionLookup::None => {
- return Ok(RadrootsNostrConnectResponse::Error {
+ .evaluate_request(&connection.connection_id, request_message)?;
+
+ match evaluation.action {
+ RadrootsNostrSignerRequestAction::Denied { reason } => {
+ Ok(RadrootsNostrConnectResponse::Error {
result: None,
- error: "unauthorized".to_owned(),
- });
+ error: reason,
+ })
}
- RadrootsNostrSignerSessionLookup::Ambiguous(_) => {
- return Ok(RadrootsNostrConnectResponse::Error {
- result: None,
- error: "ambiguous client sessions".to_owned(),
- });
+ RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => Ok(
+ RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url),
+ ),
+ RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => {
+ response_from_hint(&evaluation.connection, response_hint)
}
+ }
+ }
+
+ fn handle_sign_event_request(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ request_message: RadrootsNostrConnectRequestMessage,
+ unsigned_event: nostr::UnsignedEvent,
+ ) -> Result<RadrootsNostrConnectResponse, MycError> {
+ let connection = match self.lookup_connection(client_public_key)? {
+ Ok(connection) => connection,
+ Err(response) => return Ok(response),
};
let evaluation = self
@@ -183,11 +202,60 @@ impl MycNip46Handler {
RadrootsNostrSignerRequestAction::Challenged { auth_challenge, .. } => Ok(
RadrootsNostrConnectResponse::AuthUrl(auth_challenge.auth_url),
),
- RadrootsNostrSignerRequestAction::Allowed { response_hint, .. } => {
- response_from_hint(&evaluation.connection, response_hint)
+ RadrootsNostrSignerRequestAction::Allowed { .. } => {
+ self.sign_event_response(unsigned_event)
}
}
}
+
+ fn lookup_connection(
+ &self,
+ client_public_key: RadrootsNostrPublicKey,
+ ) -> Result<Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrConnectResponse>, MycError>
+ {
+ Ok(
+ match self
+ .signer
+ .signer_manager()
+ .lookup_session(&client_public_key, None)?
+ {
+ RadrootsNostrSignerSessionLookup::Connection(connection) => Ok(connection),
+ RadrootsNostrSignerSessionLookup::None => {
+ Err(RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "unauthorized".to_owned(),
+ })
+ }
+ RadrootsNostrSignerSessionLookup::Ambiguous(_) => {
+ Err(RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "ambiguous client sessions".to_owned(),
+ })
+ }
+ },
+ )
+ }
+
+ fn sign_event_response(
+ &self,
+ unsigned_event: nostr::UnsignedEvent,
+ ) -> Result<RadrootsNostrConnectResponse, MycError> {
+ let user_public_key = self.signer.user_identity().public_key();
+ if unsigned_event.pubkey != user_public_key {
+ return Ok(RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "sign_event pubkey does not match the managed user identity".to_owned(),
+ });
+ }
+
+ match unsigned_event.sign_with_keys(self.signer.user_identity().keys()) {
+ Ok(event) => Ok(RadrootsNostrConnectResponse::SignedEvent(event)),
+ Err(error) => Ok(RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: format!("failed to sign event: {error}"),
+ }),
+ }
+ }
}
impl MycNip46Service {
@@ -270,11 +338,6 @@ fn grant_permissions_for_new_connection(
requested_permissions: RadrootsNostrConnectPermissions,
) -> RadrootsNostrConnectPermissions {
let mut granted = requested_permissions.into_vec();
- let switch_relays =
- RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays);
- if !granted.contains(&switch_relays) {
- granted.push(switch_relays);
- }
granted.sort();
granted.dedup();
granted.into()
@@ -307,7 +370,7 @@ fn response_from_hint(
mod tests {
use nostr::nips::nip44;
use nostr::nips::nip44::Version;
- use nostr::{EventBuilder, Keys, PublicKey, SecretKey};
+ use nostr::{EventBuilder, Keys, PublicKey, SecretKey, Timestamp, UnsignedEvent};
use radroots_nostr::prelude::{RadrootsNostrTag, radroots_nostr_kind};
use radroots_nostr_connect::prelude::{
RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectMethod,
@@ -315,6 +378,7 @@ mod tests {
RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
RadrootsNostrConnectResponseEnvelope,
};
+ use serde_json::json;
use crate::app::MycRuntime;
use crate::config::MycConfig;
@@ -393,6 +457,24 @@ mod tests {
.expect("sign request")
}
+ fn sign_event_permission(kind: u16) -> RadrootsNostrConnectPermission {
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ format!("kind:{kind}"),
+ )
+ }
+
+ fn unsigned_event(pubkey: PublicKey, kind: u16, content: &str) -> UnsignedEvent {
+ serde_json::from_value(json!({
+ "pubkey": pubkey.to_hex(),
+ "created_at": Timestamp::from(1).as_secs(),
+ "kind": kind,
+ "tags": [],
+ "content": content
+ }))
+ .expect("unsigned event")
+ }
+
#[test]
fn parse_and_build_nip46_envelopes_roundtrip() {
let runtime = runtime();
@@ -472,7 +554,10 @@ mod tests {
RadrootsNostrConnectRequest::Connect {
remote_signer_public_key: runtime.signer_identity().public_key(),
secret: None,
- requested_permissions: Default::default(),
+ requested_permissions: vec![RadrootsNostrConnectPermission::new(
+ RadrootsNostrConnectMethod::SwitchRelays,
+ )]
+ .into(),
},
),
)
@@ -521,7 +606,7 @@ mod tests {
}
#[test]
- fn new_connections_auto_grant_switch_relays() {
+ fn new_connections_preserve_requested_permissions_without_expansion() {
let runtime = runtime();
let handler = handler(&runtime);
handler
@@ -532,7 +617,7 @@ mod tests {
RadrootsNostrConnectRequest::Connect {
remote_signer_public_key: runtime.signer_identity().public_key(),
secret: None,
- requested_permissions: Default::default(),
+ requested_permissions: vec![sign_event_permission(1)].into(),
},
),
)
@@ -545,8 +630,132 @@ mod tests {
.into_iter()
.next()
.expect("connection");
- assert!(connection.granted_permissions().as_slice().contains(
- &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays,)
- ));
+ assert_eq!(
+ connection.granted_permissions().as_slice(),
+ &[sign_event_permission(1)]
+ );
+ }
+
+ #[test]
+ fn sign_event_returns_signed_event_for_managed_user_key() {
+ let runtime = runtime();
+ let handler = handler(&runtime);
+ handler
+ .handle_request(
+ client_keys().public_key(),
+ RadrootsNostrConnectRequestMessage::new(
+ "req-connect",
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: runtime.signer_identity().public_key(),
+ secret: None,
+ requested_permissions: vec![sign_event_permission(1)].into(),
+ },
+ ),
+ )
+ .expect("connect");
+
+ let response = handler
+ .handle_request(
+ client_keys().public_key(),
+ RadrootsNostrConnectRequestMessage::new(
+ "req-sign",
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event(
+ runtime.user_identity().public_key(),
+ 1,
+ "hello world",
+ )),
+ ),
+ )
+ .expect("sign event");
+
+ let RadrootsNostrConnectResponse::SignedEvent(event) = response else {
+ panic!("unexpected sign_event response");
+ };
+ assert_eq!(event.pubkey, runtime.user_identity().public_key());
+ assert_eq!(event.kind.as_u16(), 1);
+ assert_eq!(event.content, "hello world");
+ assert!(event.verify_signature());
+ }
+
+ #[test]
+ fn sign_event_is_denied_without_permission() {
+ let runtime = runtime();
+ let handler = handler(&runtime);
+ handler
+ .handle_request(
+ client_keys().public_key(),
+ RadrootsNostrConnectRequestMessage::new(
+ "req-connect",
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: runtime.signer_identity().public_key(),
+ secret: None,
+ requested_permissions: Default::default(),
+ },
+ ),
+ )
+ .expect("connect");
+
+ let response = handler
+ .handle_request(
+ client_keys().public_key(),
+ RadrootsNostrConnectRequestMessage::new(
+ "req-sign",
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event(
+ runtime.user_identity().public_key(),
+ 1,
+ "hello world",
+ )),
+ ),
+ )
+ .expect("sign event");
+
+ assert_eq!(
+ response,
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "unauthorized sign_event".to_owned(),
+ }
+ );
+ }
+
+ #[test]
+ fn sign_event_rejects_pubkey_mismatch() {
+ let runtime = runtime();
+ let handler = handler(&runtime);
+ handler
+ .handle_request(
+ client_keys().public_key(),
+ RadrootsNostrConnectRequestMessage::new(
+ "req-connect",
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: runtime.signer_identity().public_key(),
+ secret: None,
+ requested_permissions: vec![sign_event_permission(1)].into(),
+ },
+ ),
+ )
+ .expect("connect");
+
+ let response = handler
+ .handle_request(
+ client_keys().public_key(),
+ RadrootsNostrConnectRequestMessage::new(
+ "req-sign",
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event(
+ client_keys().public_key(),
+ 1,
+ "hello world",
+ )),
+ ),
+ )
+ .expect("sign event");
+
+ assert_eq!(
+ response,
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "sign_event pubkey does not match the managed user identity".to_owned(),
+ }
+ );
}
}