sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 7b42fbc7cf197aa5be4dc51a5ad194bcfdbec5eb
parent 57dbb2e0d0ab3a08ec9adfc2059bdeb6647aab0d
Author: triesap <tyson@radroots.org>
Date:   Wed, 24 Jun 2026 04:53:35 +0000

sdk: harden Myc NIP-46 request policy

- add bounded SDK request policy for Myc signing
- generate production NIP-46 request ids with UUID entropy
- derive test responses from captured request ids
- cover zero-timeout and hanging-transport failures

Diffstat:
Mcrates/sdk/Cargo.toml | 2++
Mcrates/sdk/src/lib.rs | 12+++++++-----
Mcrates/sdk/src/signer_provider.rs | 110++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/sdk/tests/unit/signer_provider_tests.rs | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
4 files changed, 244 insertions(+), 43 deletions(-)

diff --git a/crates/sdk/Cargo.toml b/crates/sdk/Cargo.toml @@ -50,6 +50,7 @@ signer-adapters = [ "std", "dep:radroots_nostr_connect", "dep:radroots_nostr_signer", + "dep:tokio", "radroots_nostr/events", ] runtime = [ @@ -129,6 +130,7 @@ sqlx = { workspace = true, optional = true, default-features = false, features = "runtime-tokio", "sqlite", ] } +tokio = { workspace = true, optional = true, features = ["time"] } uuid = { workspace = true, optional = true } [[example]] diff --git a/crates/sdk/src/lib.rs b/crates/sdk/src/lib.rs @@ -96,12 +96,14 @@ pub use crate::runtime::{ }; #[cfg(all(feature = "runtime", feature = "signer-adapters"))] pub use crate::signer_provider::{ + RADROOTS_SDK_MYC_NIP46_DEFAULT_REQUEST_TIMEOUT_MS, RADROOTS_SDK_MYC_NIP46_PRODUCT_SIGN_EVENT_KINDS, RadrootsSdkLocalKeySigner, - RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, RadrootsSdkNip46TransportFuture, - RadrootsSdkSignReceipt, RadrootsSdkSignRequest, RadrootsSdkSignerCapability, - RadrootsSdkSignerMode, RadrootsSdkSignerProgress, RadrootsSdkSignerProgressSink, - RadrootsSdkSignerProvider, RadrootsSdkSignerState, RadrootsSdkSignerStatus, - radroots_sdk_myc_nip46_product_permission_strings, radroots_sdk_myc_nip46_product_permissions, + RadrootsSdkMycNip46RequestPolicy, RadrootsSdkMycNip46Signer, RadrootsSdkNip46Transport, + RadrootsSdkNip46TransportFuture, RadrootsSdkSignReceipt, RadrootsSdkSignRequest, + RadrootsSdkSignerCapability, RadrootsSdkSignerMode, RadrootsSdkSignerProgress, + RadrootsSdkSignerProgressSink, RadrootsSdkSignerProvider, RadrootsSdkSignerState, + RadrootsSdkSignerStatus, radroots_sdk_myc_nip46_product_permission_strings, + radroots_sdk_myc_nip46_product_permissions, }; #[cfg(feature = "runtime")] pub use crate::sync_runtime::{ diff --git a/crates/sdk/src/signer_provider.rs b/crates/sdk/src/signer_provider.rs @@ -20,10 +20,10 @@ use radroots_nostr_connect::prelude::{ execute_request_with_transport, }; use serde_json::json; -use std::sync::{ - Arc, - atomic::{AtomicU64, Ordering}, -}; +use std::sync::Arc; +use std::time::Duration; +use tokio::time::timeout; +use uuid::Uuid; pub type RadrootsSdkNip46TransportFuture<'a, T> = RadrootsNostrConnectClientTransportFuture<'a, T>; @@ -36,6 +36,7 @@ pub const RADROOTS_SDK_MYC_NIP46_PRODUCT_SIGN_EVENT_KINDS: [u32; 7] = [ KIND_ORDER_REVISION_DECISION, KIND_ORDER_CANCELLATION, ]; +pub const RADROOTS_SDK_MYC_NIP46_DEFAULT_REQUEST_TIMEOUT_MS: u64 = 30_000; #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] #[serde(rename_all = "snake_case")] @@ -281,13 +282,45 @@ pub trait RadrootsSdkNip46Transport: Send + Sync { -> RadrootsSdkNip46TransportFuture<'a, RadrootsNostrEvent>; } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RadrootsSdkMycNip46RequestPolicy { + request_timeout: Duration, +} + +impl RadrootsSdkMycNip46RequestPolicy { + pub fn new(request_timeout: Duration) -> Result<Self, RadrootsSdkError> { + if request_timeout.is_zero() { + return Err(RadrootsSdkError::SignerUnavailable { + mode: RadrootsSdkSignerMode::MycNip46.as_str().to_owned(), + reason: "myc_nip46 request timeout must be greater than zero".to_owned(), + }); + } + Ok(Self { request_timeout }) + } + + pub fn request_timeout(self) -> Duration { + self.request_timeout + } +} + +impl Default for RadrootsSdkMycNip46RequestPolicy { + fn default() -> Self { + Self { + request_timeout: Duration::from_millis( + RADROOTS_SDK_MYC_NIP46_DEFAULT_REQUEST_TIMEOUT_MS, + ), + } + } +} + #[derive(Clone)] pub struct RadrootsSdkMycNip46Signer { client_keys: RadrootsNostrKeys, target: RadrootsNostrConnectClientTarget, user_pubkey: RadrootsPublicKey, transport: Arc<dyn RadrootsSdkNip46Transport>, - next_request_id: Arc<AtomicU64>, + request_policy: RadrootsSdkMycNip46RequestPolicy, + request_id_generator: Arc<dyn RadrootsSdkMycNip46RequestIdGenerator>, } impl RadrootsSdkMycNip46Signer { @@ -297,6 +330,41 @@ impl RadrootsSdkMycNip46Signer { user_pubkey: impl AsRef<str>, transport: Arc<dyn RadrootsSdkNip46Transport>, ) -> Result<Self, RadrootsSdkError> { + Self::new_with_request_policy( + client_keys, + target, + user_pubkey, + transport, + RadrootsSdkMycNip46RequestPolicy::default(), + ) + } + + pub fn new_with_request_policy( + client_keys: RadrootsNostrKeys, + target: RadrootsNostrConnectClientTarget, + user_pubkey: impl AsRef<str>, + transport: Arc<dyn RadrootsSdkNip46Transport>, + request_policy: RadrootsSdkMycNip46RequestPolicy, + ) -> Result<Self, RadrootsSdkError> { + Self::new_with_request_id_generator( + client_keys, + target, + user_pubkey, + transport, + request_policy, + Arc::new(RadrootsSdkUuidNip46RequestIdGenerator), + ) + } + + fn new_with_request_id_generator( + client_keys: RadrootsNostrKeys, + target: RadrootsNostrConnectClientTarget, + user_pubkey: impl AsRef<str>, + transport: Arc<dyn RadrootsSdkNip46Transport>, + request_policy: RadrootsSdkMycNip46RequestPolicy, + request_id_generator: Arc<dyn RadrootsSdkMycNip46RequestIdGenerator>, + ) -> Result<Self, RadrootsSdkError> { + RadrootsSdkMycNip46RequestPolicy::new(request_policy.request_timeout())?; let user_pubkey = RadrootsPublicKey::parse(user_pubkey.as_ref()).map_err(|error| { RadrootsSdkError::InvalidRequest { message: format!("myc_nip46 user pubkey is invalid: {error}"), @@ -307,7 +375,8 @@ impl RadrootsSdkMycNip46Signer { target, user_pubkey, transport, - next_request_id: Arc::new(AtomicU64::new(1)), + request_policy, + request_id_generator, }) } @@ -350,13 +419,10 @@ impl RadrootsSdkMycNip46Signer { transport: self.transport.as_ref(), }; let mut progress_error = None; - let response = execute_request_with_transport( + let request_future = execute_request_with_transport( &self.client_keys, &self.target, - RadrootsNostrConnectClientRequest::new( - request_id, - sign_event_request, - ), + RadrootsNostrConnectClientRequest::new(request_id, sign_event_request), &mut adapter, |progress| { let sdk_progress = match progress { @@ -375,8 +441,11 @@ impl RadrootsSdkMycNip46Signer { } Ok(()) }, - ) - .await; + ); + let response = timeout(self.request_policy.request_timeout(), request_future) + .await + .map_err(|_| RadrootsNostrConnectError::RequestTimedOut) + .and_then(|response| response); if let Some(error) = progress_error { return Err(error); } @@ -401,8 +470,19 @@ impl RadrootsSdkMycNip46Signer { } fn next_request_id(&self) -> String { - let next = self.next_request_id.fetch_add(1, Ordering::Relaxed); - format!("radroots-sdk-myc-nip46-sign-{next}") + self.request_id_generator.next_request_id() + } +} + +trait RadrootsSdkMycNip46RequestIdGenerator: Send + Sync { + fn next_request_id(&self) -> String; +} + +struct RadrootsSdkUuidNip46RequestIdGenerator; + +impl RadrootsSdkMycNip46RequestIdGenerator for RadrootsSdkUuidNip46RequestIdGenerator { + fn next_request_id(&self) -> String { + format!("radroots-sdk-myc-nip46-sign-{}", Uuid::new_v4()) } } diff --git a/crates/sdk/tests/unit/signer_provider_tests.rs b/crates/sdk/tests/unit/signer_provider_tests.rs @@ -7,10 +7,13 @@ use radroots_events_codec::wire::{WireEventParts, to_frozen_draft}; use radroots_nostr::prelude::{RadrootsNostrEvent, RadrootsNostrSecretKey}; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientTarget, RadrootsNostrConnectError, - RadrootsNostrConnectResponse, + RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, }; use std::collections::VecDeque; +use std::future; use std::sync::{Arc, Mutex}; +use std::time::Duration; +use uuid::Uuid; const USER_SECRET_KEY_HEX: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; @@ -104,21 +107,45 @@ fn response_event( } struct MockNip46Transport { + remote_keys: RadrootsNostrKeys, + responses: Mutex<VecDeque<MockNip46Response>>, published: Mutex<Vec<RadrootsNostrEvent>>, inbound: Mutex<VecDeque<RadrootsNostrEvent>>, } +enum MockNip46Response { + Respond(RadrootsNostrConnectResponse), +} + impl MockNip46Transport { - fn new(inbound: Vec<RadrootsNostrEvent>) -> Self { + fn new(remote_keys: RadrootsNostrKeys, responses: Vec<MockNip46Response>) -> Self { Self { + remote_keys, + responses: Mutex::new(responses.into()), published: Mutex::new(Vec::new()), - inbound: Mutex::new(inbound.into()), + inbound: Mutex::new(VecDeque::new()), } } fn published(&self) -> Vec<RadrootsNostrEvent> { self.published.lock().expect("published lock").clone() } + + fn published_request_messages(&self) -> Vec<RadrootsNostrConnectRequestMessage> { + self.published() + .iter() + .map(|event| request_message_from_event(&self.remote_keys, event)) + .collect() + } +} + +fn request_message_from_event( + remote_keys: &RadrootsNostrKeys, + event: &RadrootsNostrEvent, +) -> RadrootsNostrConnectRequestMessage { + let payload = nip44::decrypt(remote_keys.secret_key(), &event.pubkey, &event.content) + .expect("request payload"); + serde_json::from_str(payload.as_str()).expect("request message") } impl RadrootsSdkNip46Transport for MockNip46Transport { @@ -127,6 +154,22 @@ impl RadrootsSdkNip46Transport for MockNip46Transport { event: RadrootsNostrEvent, ) -> RadrootsSdkNip46TransportFuture<'a, ()> { self.published.lock().expect("published lock").push(event); + let response = self.responses.lock().expect("responses lock").pop_front(); + if let Some(MockNip46Response::Respond(response)) = response { + let event = self + .published + .lock() + .expect("published lock") + .last() + .cloned(); + let event = event.expect("published request event"); + let request = request_message_from_event(&self.remote_keys, &event); + let response = response_event(&self.remote_keys, event.pubkey, &request.id, response); + self.inbound + .lock() + .expect("inbound lock") + .push_back(response); + } Box::pin(async { Ok(()) }) } @@ -138,6 +181,36 @@ impl RadrootsSdkNip46Transport for MockNip46Transport { } } +struct HangingNip46Transport { + published: Mutex<Vec<RadrootsNostrEvent>>, +} + +impl HangingNip46Transport { + fn new() -> Self { + Self { + published: Mutex::new(Vec::new()), + } + } +} + +impl RadrootsSdkNip46Transport for HangingNip46Transport { + fn publish_request_event<'a>( + &'a self, + event: RadrootsNostrEvent, + ) -> RadrootsSdkNip46TransportFuture<'a, ()> { + self.published.lock().expect("published lock").push(event); + Box::pin(async { Ok(()) }) + } + + fn next_response_event<'a>( + &'a self, + ) -> RadrootsSdkNip46TransportFuture<'a, RadrootsNostrEvent> { + Box::pin(future::pending::< + Result<RadrootsNostrEvent, RadrootsNostrConnectError>, + >()) + } +} + #[tokio::test] async fn local_key_provider_signs_authorized_frozen_draft() { let signer = RadrootsSdkLocalKeySigner::new(user_keys()).expect("signer"); @@ -202,13 +275,12 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() { let signed = radroots_nostr::prelude::radroots_nostr_sign_frozen_draft(&user_keys, &draft) .expect("signed"); let signed_event = RadrootsNostrEvent::from_json(signed.raw_json.as_str()).expect("event"); - let inbound = vec![response_event( - &remote_keys, - client_keys.public_key(), - "radroots-sdk-myc-nip46-sign-1", - RadrootsNostrConnectResponse::SignedEvent(signed_event), - )]; - let transport = Arc::new(MockNip46Transport::new(inbound)); + let transport = Arc::new(MockNip46Transport::new( + remote_keys.clone(), + vec![MockNip46Response::Respond( + RadrootsNostrConnectResponse::SignedEvent(signed_event), + )], + )); let target = RadrootsNostrConnectClientTarget::new( remote_keys.public_key(), vec![nostr::RelayUrl::parse("wss://relay.example.com").expect("relay")], @@ -244,6 +316,12 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() { ); assert_eq!(receipt.signed_event, signed); assert_eq!(transport.published().len(), 1); + let request_messages = transport.published_request_messages(); + let request_id = request_messages[0] + .id + .strip_prefix("radroots-sdk-myc-nip46-sign-") + .expect("request id prefix"); + Uuid::parse_str(request_id).expect("uuid request id"); assert_eq!( progress, vec![ @@ -261,13 +339,12 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() { async fn myc_nip46_provider_reports_auth_challenge_progress_and_timeout() { let client_keys = client_keys(); let remote_keys = remote_keys(); - let auth = response_event( - &remote_keys, - client_keys.public_key(), - "radroots-sdk-myc-nip46-sign-1", - RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()), - ); - let transport = Arc::new(MockNip46Transport::new(vec![auth])); + let transport = Arc::new(MockNip46Transport::new( + remote_keys.clone(), + vec![MockNip46Response::Respond( + RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()), + )], + )); let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new()); let signer = RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport) @@ -307,6 +384,46 @@ async fn myc_nip46_provider_reports_auth_challenge_progress_and_timeout() { } #[tokio::test] +async fn myc_nip46_provider_rejects_zero_timeout_policy() { + let error = RadrootsSdkMycNip46RequestPolicy::new(Duration::ZERO).expect_err("zero timeout"); + + assert!(matches!( + error, + RadrootsSdkError::SignerUnavailable { ref mode, ref reason } + if mode == "myc_nip46" && reason.contains("timeout") + )); +} + +#[tokio::test] +async fn myc_nip46_provider_times_out_hanging_transport() { + let client_keys = client_keys(); + let remote_keys = remote_keys(); + let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new()); + let transport = Arc::new(HangingNip46Transport::new()); + let policy = RadrootsSdkMycNip46RequestPolicy::new(Duration::from_millis(5)).expect("policy"); + let signer = RadrootsSdkMycNip46Signer::new_with_request_policy( + client_keys, + target, + USER_PUBLIC_KEY_HEX, + transport, + policy, + ) + .expect("signer"); + let draft = frozen_draft(); + let actor = actor(); + + let error = signer + .sign(RadrootsSdkSignRequest::new("farm.publish", &actor, &draft)) + .await + .expect_err("timeout"); + + assert!(matches!( + error, + RadrootsSdkError::SignerRequestTimedOut { ref mode } if mode == "myc_nip46" + )); +} + +#[tokio::test] async fn myc_nip46_provider_rejects_returned_event_drift() { let draft = frozen_draft(); let wrong_user_keys = remote_keys(); @@ -390,12 +507,12 @@ async fn myc_nip46_provider_rejects_returned_event_drift() { let client_keys = client_keys(); let remote_keys = remote_keys(); let signed_event = sign_event(&signing_keys, &drifted_draft); - let transport = Arc::new(MockNip46Transport::new(vec![response_event( - &remote_keys, - client_keys.public_key(), - "radroots-sdk-myc-nip46-sign-1", - RadrootsNostrConnectResponse::SignedEvent(signed_event), - )])); + let transport = Arc::new(MockNip46Transport::new( + remote_keys.clone(), + vec![MockNip46Response::Respond( + RadrootsNostrConnectResponse::SignedEvent(signed_event), + )], + )); let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new()); let signer = RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport)