radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 0ad748f85c2dc18d75d1cfeb1d582d8d0451a948
parent dc617ddb71230a8fae98d648c9be7547f96edc5b
Author: triesap <tyson@radroots.org>
Date:   Sun, 19 Jul 2026 20:30:22 +0000

nip46: bind protocol transport and signer responses

- complete kind-24133 messages through one local protocol signer
- relay completed NIP-46 events through transport-only publication
- bind remote sign_event results to the exact author and canonical ID
- migrate strict service Profiles and low-level transport fixtures
- guard the retired generic event-authoring surface

Diffstat:
MCargo.lock | 9+++++++++
MREADME | 6++++++
Msrc/app/runtime.rs | 14+++++---------
Msrc/core/transport_publish.rs | 19+++++++++----------
Msrc/transport/jsonrpc/methods/nip46/connect.rs | 16++++++++--------
Msrc/transport/jsonrpc/methods/nip46/session_authorize.rs | 14++++++--------
Msrc/transport/jsonrpc/methods/transport_publish.rs | 21+++++++++------------
Msrc/transport/jsonrpc/nip46/client.rs | 119++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/transport/jsonrpc/server.rs | 22+++++++++-------------
Msrc/transport/nostr/listener.rs | 15++++++---------
Msrc/transport/nostr/mod.rs | 1+
Asrc/transport/nostr/protocol.rs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtests/source_boundary.rs | 41+++++++++++++++++++++++++++++++++++++++++
13 files changed, 267 insertions(+), 85 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1880,6 +1880,7 @@ dependencies = [ "mediatype", "serde", "sha2", + "unicode-general-category", "url", ] @@ -1904,6 +1905,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "unicode-general-category", "url", ] @@ -1912,6 +1914,7 @@ name = "radroots_event_codec" version = "1.0.0-alpha.1" dependencies = [ "nostr", + "radroots_blossom", "radroots_core", "radroots_event", "serde", @@ -3113,6 +3116,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/README b/README @@ -3,6 +3,12 @@ This is the README for `radrootsd` which is a long-running daemon for authenticated publishing and optional NIP-46 control on Rad Roots networks. +NIP-46 kind-24133 request and response events are locally completed through a +dedicated protocol helper and then relayed as already-signed transport +messages. Remote `sign_event` results are accepted only when the author and +canonical event ID match the exact unsigned request and the complete NIP-01 +event verifies. + ## Copyright Except as otherwise noted, all files in the `radrootsd` distribution are diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -18,10 +18,9 @@ use crate::transport::nostr::listener::spawn_nip46_listener; #[cfg(not(test))] use clap::Parser; use radroots_event::profile::RadrootsAuthoredProfile; -use radroots_event_codec::profile::authored::authored_profile_to_wire_parts; use radroots_nostr::prelude::{ RadrootsNostrApplicationHandlerSpec, RadrootsNostrKind, - radroots_nostr_build_application_handler_event, radroots_nostr_build_event, + radroots_nostr_build_application_handler_event, radroots_nostr_build_profile_event, }; use std::path::PathBuf; @@ -297,13 +296,10 @@ fn build_service_presence_events( profile: &RadrootsAuthoredProfile, handler_spec: &RadrootsNostrApplicationHandlerSpec, ) -> Result<(nostr::Event, nostr::Event)> { - let profile_wire = authored_profile_to_wire_parts(profile) - .context("encode strict authored service Profile")?; - let profile_event = - radroots_nostr_build_event(profile_wire.kind, profile_wire.content, profile_wire.tags) - .context("build service Profile event")? - .sign_with_keys(identity.keys()) - .context("sign service Profile event")?; + let profile_event = radroots_nostr_build_profile_event(profile) + .context("build service Profile event")? + .sign_with_keys(identity.keys()) + .context("sign service Profile event")?; let handler_event = radroots_nostr_build_application_handler_event(handler_spec) .context("build NIP-89 application handler event")? .sign_with_keys(identity.keys()) diff --git a/src/core/transport_publish.rs b/src/core/transport_publish.rs @@ -3259,8 +3259,9 @@ mod tests { NostrRelayUrlPolicy, TransportPublishConfig, TransportPublishNostrConfig, }; use nostr::JsonUtil; + use nostr::{EventBuilder, Kind, Tag}; use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::{RadrootsNostrTimestamp, radroots_nostr_build_event}; + use radroots_nostr::prelude::RadrootsNostrTimestamp; use radroots_transport::{ RADROOTS_RETICULUM_ENDPOINT_URI, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportTarget, @@ -3383,15 +3384,13 @@ mod tests { } fn signed_event(identity: &RadrootsIdentity, content: &str) -> String { - let event = radroots_nostr_build_event( - 30_402, - content, - vec![vec!["d".to_owned(), "listing-1".to_owned()]], - ) - .expect("event builder") - .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) - .sign_with_keys(identity.keys()) - .expect("signed event"); + // Transport tests require an already-signed wire fixture; they do not + // exercise a Radroots product-authoring boundary. + let event = EventBuilder::new(Kind::Custom(30_402), content) + .tag(Tag::identifier("listing-1")) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(identity.keys()) + .expect("signed event"); event.as_json() } diff --git a/src/transport/jsonrpc/methods/nip46/connect.rs b/src/transport/jsonrpc/methods/nip46/connect.rs @@ -15,13 +15,14 @@ use crate::transport::jsonrpc::nip46::connection::{ }; use crate::transport::jsonrpc::params::DEFAULT_TIMEOUT_SECS; use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use crate::transport::nostr::protocol::sign_nip46_message; use nostr::JsonUtil; use nostr::nips::{nip44, nip46::NostrConnectMessage, nip46::NostrConnectRequest}; use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKeys, - RadrootsNostrKind, RadrootsNostrPublicKey, RadrootsNostrRelayPoolNotification, - RadrootsNostrSecretKey, RadrootsNostrSubscriptionId, RadrootsNostrTimestamp, - radroots_nostr_filter_tag, radroots_nostr_parse_pubkey, + RadrootsNostrClient, RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, + RadrootsNostrPublicKey, RadrootsNostrRelayPoolNotification, RadrootsNostrSecretKey, + RadrootsNostrSubscriptionId, RadrootsNostrTimestamp, radroots_nostr_filter_tag, + radroots_nostr_parse_pubkey, }; #[derive(Debug, Deserialize)] @@ -287,11 +288,10 @@ async fn send_connect_request( remote_signer_pubkey: &RadrootsNostrPublicKey, message: NostrConnectMessage, ) -> Result<(), RpcError> { - let event = - RadrootsNostrEventBuilder::nostr_connect(client_keys, *remote_signer_pubkey, message) - .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; + let event = sign_nip46_message(client_keys, *remote_signer_pubkey, message) + .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; client - .send_event_builder(event) + .send_event(&event) .await .map_err(|e| RpcError::Other(format!("nip46 connect request failed: {e}")))?; Ok(()) diff --git a/src/transport/jsonrpc/methods/nip46/session_authorize.rs b/src/transport/jsonrpc/methods/nip46/session_authorize.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use nostr::nips::nip46::NostrConnectMessage; use crate::transport::jsonrpc::{MethodRegistry, RpcContext, RpcError}; -use radroots_nostr::prelude::RadrootsNostrEventBuilder; +use crate::transport::nostr::protocol::sign_nip46_message; #[derive(Debug, Deserialize)] struct Nip46SessionAuthorizeParams { @@ -42,13 +42,11 @@ pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Res ) .await; let message = NostrConnectMessage::response(pending.request_id, response); - let response_event = RadrootsNostrEventBuilder::nostr_connect( - &ctx.state.keys, - pending.client_pubkey, - message, - ) - .map_err(|err| RpcError::Other(format!("nip46 response build failed: {err}")))?; - let _ = ctx.state.client.send_event_builder(response_event).await; + let response_event = + sign_nip46_message(&ctx.state.keys, pending.client_pubkey, message).map_err( + |err| RpcError::Other(format!("nip46 response build failed: {err}")), + )?; + let _ = ctx.state.client.send_event(&response_event).await; replayed = true; } Ok::<Nip46SessionAuthorizeResponse, RpcError>(Nip46SessionAuthorizeResponse { diff --git a/src/transport/jsonrpc/methods/transport_publish.rs b/src/transport/jsonrpc/methods/transport_publish.rs @@ -141,25 +141,22 @@ mod tests { use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; use jsonrpsee::server::RpcModule; use nostr::JsonUtil; + use nostr::{EventBuilder, Kind, Tag}; use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::{ - RadrootsNostrMetadata, RadrootsNostrTimestamp, radroots_nostr_build_event, - }; + use radroots_nostr::prelude::{RadrootsNostrMetadata, RadrootsNostrTimestamp}; use radroots_transport_nostr::RadrootsMockRelayPublishAdapter; use radroots_transport_publish_protocol::{ NostrPublishTargetSourcePolicy, TransportPublishTargetPolicyName, }; fn signed_event(identity: &RadrootsIdentity) -> String { - let event = radroots_nostr_build_event( - 30_402, - "{}", - vec![vec!["d".to_owned(), "listing-1".to_owned()]], - ) - .expect("event builder") - .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) - .sign_with_keys(identity.keys()) - .expect("signed event"); + // This method accepts an already-signed wire event; construct the test + // fixture at that explicit low-level interoperability boundary. + let event = EventBuilder::new(Kind::Custom(30_402), "{}") + .tag(Tag::identifier("listing-1")) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(identity.keys()) + .expect("signed event"); event.as_json() } diff --git a/src/transport/jsonrpc/nip46/client.rs b/src/transport/jsonrpc/nip46/client.rs @@ -4,6 +4,7 @@ use std::time::Duration; use crate::core::nip46::session::Nip46Session; use crate::transport::jsonrpc::{RpcError, params::DEFAULT_TIMEOUT_SECS}; +use crate::transport::nostr::protocol::sign_nip46_message; use nostr::JsonUtil; use nostr::UnsignedEvent; use nostr::nips::{ @@ -11,18 +12,22 @@ use nostr::nips::{ nip46::{NostrConnectMessage, NostrConnectMethod, NostrConnectRequest, ResponseResult}, }; use radroots_nostr::prelude::{ - RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind, - RadrootsNostrRelayPoolNotification, RadrootsNostrSubscriptionId, RadrootsNostrTimestamp, - radroots_nostr_filter_tag, + RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrRelayPoolNotification, + RadrootsNostrSubscriptionId, RadrootsNostrTimestamp, radroots_nostr_filter_tag, }; use tokio::sync::broadcast; use tokio::time::sleep; pub async fn sign_event( session: &Nip46Session, - unsigned: UnsignedEvent, + mut unsigned: UnsignedEvent, label: &str, ) -> Result<nostr::Event, RpcError> { + unsigned.verify_id().map_err(|_| { + RpcError::InvalidParams(format!("nip46 {label} unsigned event ID mismatch")) + })?; + let expected_public_key = unsigned.pubkey; + let expected_event_id = unsigned.id(); let req = NostrConnectRequest::SignEvent(unsigned); let response = request(session, req, label).await?; let response = response @@ -43,11 +48,7 @@ pub async fn sign_event( None => return Err(RpcError::Other(format!("nip46 {label} missing response"))), }; - event - .verify() - .map_err(|e| RpcError::Other(format!("nip46 {label} invalid event: {e}")))?; - - Ok(event) + validate_signed_event_response(expected_public_key, expected_event_id, event, label) } pub async fn request( @@ -70,16 +71,12 @@ pub async fn request( .subscribe(filter, None) .await .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; - let event = RadrootsNostrEventBuilder::nostr_connect( - &session.client_keys, - session.remote_signer_pubkey, - message, - ) - .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; + let event = sign_nip46_message(&session.client_keys, session.remote_signer_pubkey, message) + .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}")))?; if let Err(error) = session .client - .send_event_builder(event) + .send_event(&event) .await .map_err(|e| RpcError::Other(format!("nip46 {label} failed: {e}"))) { @@ -97,6 +94,28 @@ pub async fn request( .await } +fn validate_signed_event_response( + expected_public_key: nostr::PublicKey, + expected_event_id: nostr::EventId, + event: nostr::Event, + label: &str, +) -> Result<nostr::Event, RpcError> { + if event.pubkey != expected_public_key { + return Err(RpcError::Other(format!( + "nip46 {label} response author mismatch" + ))); + } + if event.id != expected_event_id { + return Err(RpcError::Other(format!( + "nip46 {label} response event ID mismatch" + ))); + } + event + .verify() + .map_err(|_| RpcError::Other(format!("nip46 {label} response event is invalid")))?; + Ok(event) +} + fn response_filter( session: &Nip46Session, since: RadrootsNostrTimestamp, @@ -160,3 +179,71 @@ async fn wait_for_response( } } } + +#[cfg(test)] +mod tests { + use nostr::{EventBuilder, EventId, Kind, Timestamp}; + use radroots_nostr::prelude::RadrootsNostrKeys; + + use super::validate_signed_event_response; + + fn signed_fixture() -> (nostr::UnsignedEvent, nostr::Event) { + let keys = RadrootsNostrKeys::generate(); + let unsigned = EventBuilder::new(Kind::Custom(30_001), "checked") + .custom_created_at(Timestamp::from_secs(1_784_347_200)) + .build(keys.public_key()); + let event = unsigned + .clone() + .sign_with_keys(&keys) + .expect("signed fixture"); + (unsigned, event) + } + + #[test] + fn sign_event_response_accepts_only_the_exact_valid_event() { + let (mut unsigned, event) = signed_fixture(); + let expected_public_key = unsigned.pubkey; + let expected_event_id = unsigned.id(); + assert!( + validate_signed_event_response( + expected_public_key, + expected_event_id, + event.clone(), + "test", + ) + .is_ok() + ); + + let (_, wrong_author) = signed_fixture(); + let error = validate_signed_event_response( + expected_public_key, + expected_event_id, + wrong_author, + "test", + ) + .expect_err("wrong author"); + assert!(error.to_string().contains("author mismatch")); + + let mut wrong_id = event.clone(); + wrong_id.id = EventId::all_zeros(); + let error = validate_signed_event_response( + expected_public_key, + expected_event_id, + wrong_id, + "test", + ) + .expect_err("wrong event ID"); + assert!(error.to_string().contains("event ID mismatch")); + + let mut wrong_signature = event; + wrong_signature.content.push('!'); + let error = validate_signed_event_response( + expected_public_key, + expected_event_id, + wrong_signature, + "test", + ) + .expect_err("invalid event"); + assert!(error.to_string().contains("event is invalid")); + } +} diff --git a/src/transport/jsonrpc/server.rs b/src/transport/jsonrpc/server.rs @@ -134,10 +134,9 @@ mod tests { use crate::transport::jsonrpc::{MethodRegistry, RpcContext}; use jsonrpsee::server::RpcModule; use nostr::JsonUtil; + use nostr::{EventBuilder, Kind, Tag}; use radroots_identity::RadrootsIdentity; - use radroots_nostr::prelude::{ - RadrootsNostrMetadata, RadrootsNostrTimestamp, radroots_nostr_build_event, - }; + use radroots_nostr::prelude::{RadrootsNostrMetadata, RadrootsNostrTimestamp}; use radroots_transport_nostr::RadrootsMockRelayPublishAdapter; use radroots_transport_publish_protocol::{ NostrPublishTargetSourcePolicy, TransportPublishTargetPolicyName, @@ -156,16 +155,13 @@ mod tests { } fn signed_event_json(identity: &RadrootsIdentity) -> String { - radroots_nostr_build_event( - 30_402, - "{}", - vec![vec!["d".to_owned(), "listing-1".to_owned()]], - ) - .expect("event builder") - .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) - .sign_with_keys(identity.keys()) - .expect("signed event") - .as_json() + // The JSON-RPC server consumes an already-signed transport fixture. + EventBuilder::new(Kind::Custom(30_402), "{}") + .tag(Tag::identifier("listing-1")) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(identity.keys()) + .expect("signed event") + .as_json() } async fn post_json(addr: SocketAddr, body: &str, token: Option<&str>) -> String { diff --git a/src/transport/nostr/listener.rs b/src/transport/nostr/listener.rs @@ -14,9 +14,10 @@ use crate::core::nip46::session::{ Nip46Session, PendingNostrRequest, session_expires_at, sign_event_allowed, }; use crate::core::state::Radrootsd; +use crate::transport::nostr::protocol::sign_nip46_message; use radroots_nostr::prelude::{ - RadrootsNostrEventBuilder, RadrootsNostrFilter, RadrootsNostrKind, - RadrootsNostrRelayPoolNotification, RadrootsNostrTimestamp, radroots_nostr_filter_tag, + RadrootsNostrFilter, RadrootsNostrKind, RadrootsNostrRelayPoolNotification, + RadrootsNostrTimestamp, radroots_nostr_filter_tag, }; const DEFAULT_TIMEOUT_SECS: u64 = 10; @@ -90,13 +91,9 @@ async fn run_nip46_listener(radrootsd: Radrootsd) -> Result<()> { }; let response = handle_request(&radrootsd, &event.pubkey, &request_id, request).await; let response_message = NostrConnectMessage::response(request_id, response); - let response_event = RadrootsNostrEventBuilder::nostr_connect( - &radrootsd.keys, - event.pubkey, - response_message, - ) - .map_err(|err| anyhow!("nip46 response build failed: {err}"))?; - let _ = radrootsd.client.send_event_builder(response_event).await; + let response_event = sign_nip46_message(&radrootsd.keys, event.pubkey, response_message) + .map_err(|err| anyhow!("nip46 response build failed: {err}"))?; + let _ = radrootsd.client.send_event(&response_event).await; } } diff --git a/src/transport/nostr/mod.rs b/src/transport/nostr/mod.rs @@ -1 +1,2 @@ pub mod listener; +pub(crate) mod protocol; diff --git a/src/transport/nostr/protocol.rs b/src/transport/nostr/protocol.rs @@ -0,0 +1,55 @@ +#![forbid(unsafe_code)] + +use nostr::nips::nip46::NostrConnectMessage; +use nostr::{Event, EventBuilder}; +use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrPublicKey}; + +/// Encrypts and locally signs a NIP-46 transport message. +/// +/// The completed event is returned for transport-only publication. NIP-46 +/// kind 24133 is protocol traffic, not a generic Radroots product-authoring +/// surface. +pub(crate) fn sign_nip46_message( + sender_keys: &RadrootsNostrKeys, + receiver_pubkey: RadrootsNostrPublicKey, + message: NostrConnectMessage, +) -> Result<Event, nostr::event::builder::Error> { + EventBuilder::nostr_connect(sender_keys, receiver_pubkey, message)?.sign_with_keys(sender_keys) +} + +#[cfg(test)] +mod tests { + use nostr::JsonUtil; + use nostr::nips::{nip44, nip46::NostrConnectRequest}; + use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrKind}; + + use super::sign_nip46_message; + + #[test] + fn signed_nip46_message_is_bound_to_sender_and_receiver() { + let sender = RadrootsNostrKeys::generate(); + let receiver = RadrootsNostrKeys::generate(); + let message = nostr::nips::nip46::NostrConnectMessage::request(&NostrConnectRequest::Ping); + let request_id = message.id().to_owned(); + + let event = sign_nip46_message(&sender, receiver.public_key(), message) + .expect("signed NIP-46 message"); + + assert_eq!(event.kind, RadrootsNostrKind::NostrConnect); + assert_eq!(event.pubkey, sender.public_key()); + assert!( + event + .tags + .iter() + .any(|tag| { tag.as_slice() == ["p".to_owned(), receiver.public_key().to_hex()] }) + ); + assert!(event.verify().is_ok()); + + let plaintext = nip44::decrypt(receiver.secret_key(), &sender.public_key(), &event.content) + .expect("decrypt NIP-46 message"); + let decrypted = nostr::nips::nip46::NostrConnectMessage::from_json(plaintext) + .expect("parse NIP-46 message"); + assert!(decrypted.is_request()); + assert_eq!(decrypted.id(), request_id); + } +} diff --git a/tests/source_boundary.rs b/tests/source_boundary.rs @@ -690,3 +690,44 @@ fn line_number(source: &str, index: usize) -> usize { .count() + 1 } + +#[test] +fn nip46_transport_uses_completed_events_and_exact_sign_event_binding() { + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let protocol = read_source( + manifest_dir + .join("src/transport/nostr/protocol.rs") + .as_path(), + ); + let client = read_source( + manifest_dir + .join("src/transport/jsonrpc/nip46/client.rs") + .as_path(), + ); + let production_sources = [ + "src/transport/nostr/listener.rs", + "src/transport/jsonrpc/nip46/client.rs", + "src/transport/jsonrpc/methods/nip46/connect.rs", + "src/transport/jsonrpc/methods/nip46/session_authorize.rs", + ] + .map(|path| read_source(manifest_dir.join(path).as_path())) + .join("\n"); + + assert!(protocol.contains("EventBuilder::nostr_connect")); + assert!(protocol.contains(".sign_with_keys(sender_keys)")); + assert!(!production_sources.contains("RadrootsNostrEventBuilder")); + assert!(!production_sources.contains("radroots_nostr_build_event")); + assert!(!production_sources.contains(".send_event_builder(")); + for required in [ + "unsigned.verify_id()", + "let expected_event_id = unsigned.id()", + "event.pubkey != expected_public_key", + "event.id != expected_event_id", + ".verify()", + ] { + assert!( + client.contains(required), + "NIP-46 client must retain exact sign_event binding witness `{required}`" + ); + } +}