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