cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 255f5d50707f732590b1813efa247e0a9e896588
parent 80cb62136204c5d7003f7834b42de22a454e781c
Author: triesap <tyson@radroots.org>
Date:   Sat, 25 Apr 2026 09:26:25 +0000

daemon: match signer sessions by user pubkey

Diffstat:
Msrc/runtime/daemon.rs | 76+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mtests/listing.rs | 31+++++++++++++++++++++----------
Mtests/order.rs | 27+++++++++++++++++++++------
3 files changed, 111 insertions(+), 23 deletions(-)

diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs @@ -571,6 +571,46 @@ fn nip46_sessions_with_target( call(target, "nip46.session.list", None, RpcAuthMode::None) } +fn hydrate_nip46_session_user_pubkeys( + target: &RpcTarget, + sessions: Vec<Nip46SessionRemote>, +) -> Result<Vec<Nip46SessionRemote>, DaemonRpcError> { + if sessions + .iter() + .all(|session| session_user_pubkey(session).is_some()) + { + return Ok(sessions); + } + + let sdk = radrootsd_sdk_client(target)?; + let signer_sessions = sdk.radrootsd().signer_sessions(); + sessions + .into_iter() + .map(|mut session| { + if session_user_pubkey(&session).is_none() { + let session_ref = + SdkRadrootsdSignerSessionRef::from_session_id(session.session_id.clone()); + let public_key = block_on_sdk(signer_sessions.get_public_key(&session_ref))? + .map_err(|error| { + DaemonRpcError::Remote(format!( + "failed to hydrate signer session `{}` user pubkey: {error}", + session.session_id + )) + })?; + let pubkey = public_key.pubkey.trim().to_owned(); + if pubkey.is_empty() { + return Err(DaemonRpcError::InvalidResponse(format!( + "hydrated signer session `{}` reported an empty user pubkey", + session.session_id + ))); + } + session.user_pubkey = Some(pubkey); + } + Ok(session) + }) + .collect() +} + fn actor_write_target(config: &RuntimeConfig) -> Result<RpcTarget, DaemonRpcError> { let resolved = provider::resolve_actor_write_plane_target(config).map_err(DaemonRpcError::Unconfigured)?; @@ -591,11 +631,17 @@ fn actor_write_sdk_client( config: &RuntimeConfig, ) -> Result<radroots_sdk::RadrootsSdkClient, DaemonRpcError> { let target = actor_write_target(config)?; + radrootsd_sdk_client(&target) +} + +fn radrootsd_sdk_client( + target: &RpcTarget, +) -> Result<radroots_sdk::RadrootsSdkClient, DaemonRpcError> { let mut sdk_config = RadrootsSdkConfig::custom(); sdk_config.transport = SdkTransportMode::Radrootsd; sdk_config.signer = SignerConfig::Nip46; - sdk_config.radrootsd.endpoint = Some(target.url); - let Some(bridge_bearer_token) = target.bridge_bearer_token else { + sdk_config.radrootsd.endpoint = Some(target.url.clone()); + let Some(bridge_bearer_token) = target.bridge_bearer_token.clone() else { return Err(DaemonRpcError::Unconfigured( "actor write plane target is missing a bridge bearer token".to_owned(), )); @@ -755,7 +801,8 @@ pub fn resolve_signer_session_id( signer_authority: Option<&ActorWriteSignerAuthority>, ) -> Result<String, DaemonRpcError> { let target = actor_write_target(config)?; - let sessions = nip46_sessions_with_target(&target)?; + let sessions = + hydrate_nip46_session_user_pubkeys(&target, nip46_sessions_with_target(&target)?)?; if let Some(session_id) = requested_session_id { let Some(session) = sessions @@ -808,10 +855,16 @@ fn validate_signer_session( session.session_id ))); } - if !session.signer_pubkey.eq_ignore_ascii_case(actor_pubkey) { + let Some(user_pubkey) = session_user_pubkey(session) else { return Err(DaemonRpcError::Unconfigured(format!( - "requested signer session `{}` signer pubkey `{}` does not match {actor_role} pubkey `{actor_pubkey}`", - session.session_id, session.signer_pubkey + "requested signer session `{}` did not report a user pubkey", + session.session_id + ))); + }; + if !user_pubkey.eq_ignore_ascii_case(actor_pubkey) { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{}` user pubkey `{}` does not match {actor_role} pubkey `{actor_pubkey}`", + session.session_id, user_pubkey ))); } if !sign_event_allowed(&session.permissions, event_kind) { @@ -831,11 +884,20 @@ fn session_matches_actor( signer_authority: Option<&ActorWriteSignerAuthority>, ) -> bool { session.authorized - && session.signer_pubkey.eq_ignore_ascii_case(actor_pubkey) + && session_user_pubkey(session) + .is_some_and(|user_pubkey| user_pubkey.eq_ignore_ascii_case(actor_pubkey)) && sign_event_allowed(&session.permissions, event_kind) && signer_authority_matches(session, signer_authority) } +fn session_user_pubkey(session: &Nip46SessionRemote) -> Option<&str> { + session + .user_pubkey + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) +} + fn validate_signer_authority( session: &Nip46SessionRemote, signer_authority: Option<&ActorWriteSignerAuthority>, diff --git a/tests/listing.rs b/tests/listing.rs @@ -802,19 +802,29 @@ fn listing_publish_uses_myc_binding_before_resolving_daemon_signer_session() { let requests = Arc::new(Mutex::new(Vec::<Value>::new())); let recorded = Arc::clone(&requests); let session_account_id = account_id.clone(); + let provider_pubkey = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let server = MockRpcServer::start(move |body, auth_header| { recorded.lock().expect("recorded").push(body.clone()); match body["method"].as_str().unwrap_or_default() { "nip46.session.list" => { assert_eq!(auth_header, None); - MockRpcResponse::success(json!([sample_session_with_authority( + let mut session = sample_session_with_authority( "sess_publish_01", - seller_pubkey.as_str(), + provider_pubkey, &["sign_event"], true, Some(session_account_id.as_str()), Some("conn_listing_binding_01"), - )])) + ); + session["user_pubkey"] = Value::Null; + MockRpcResponse::success(json!([session])) + } + "nip46.get_public_key" => { + assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret")); + assert_eq!(body["params"]["session_id"], "sess_publish_01"); + MockRpcResponse::success(json!({ + "pubkey": seller_pubkey.as_str() + })) } "bridge.listing.publish" => { assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret")); @@ -879,23 +889,24 @@ managed_account_ref = "{account_id}" assert_eq!(publish_json["requested_signer_session_id"], Value::Null); let recorded = requests.lock().expect("requests"); - assert_eq!(recorded.len(), 2); + assert_eq!(recorded.len(), 3); assert_eq!(recorded[0]["method"], "nip46.session.list"); - assert_eq!(recorded[1]["method"], "bridge.listing.publish"); + assert_eq!(recorded[1]["method"], "nip46.get_public_key"); + assert_eq!(recorded[2]["method"], "bridge.listing.publish"); assert_eq!( - recorded[1]["params"]["signer_session_id"], + recorded[2]["params"]["signer_session_id"], "sess_publish_01" ); assert_eq!( - recorded[1]["params"]["signer_authority"]["provider_runtime_id"], + recorded[2]["params"]["signer_authority"]["provider_runtime_id"], "myc" ); assert_eq!( - recorded[1]["params"]["signer_authority"]["account_identity_id"], + recorded[2]["params"]["signer_authority"]["account_identity_id"], account_id ); assert_eq!( - recorded[1]["params"]["signer_authority"]["provider_signer_session_id"], + recorded[2]["params"]["signer_authority"]["provider_signer_session_id"], "conn_listing_binding_01" ); } @@ -1717,7 +1728,7 @@ fn sample_session_with_authority( "role": "remote_signer", "client_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "signer_pubkey": signer_pubkey, - "user_pubkey": Value::Null, + "user_pubkey": signer_pubkey, "relays": ["wss://relay.one"], "permissions": permissions, "auth_required": false, diff --git a/tests/order.rs b/tests/order.rs @@ -1267,6 +1267,7 @@ fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); let recorded = Arc::clone(&requests); let session_account_id = account_id.clone(); + let provider_pubkey = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; let server = MockRpcServer::start(move |body, auth_header| { recorded .lock() @@ -1274,18 +1275,27 @@ fn order_submit_uses_myc_binding_before_resolving_daemon_signer_session() { .push(MockRpcRequest { body: body.clone(), method: body["method"].as_str().unwrap_or_default().to_owned(), - auth_header, + auth_header: auth_header.clone(), }); match body["method"].as_str().unwrap_or_default() { "nip46.session.list" => { - MockRpcResponse::success(json!([sample_session_with_authority( + let mut session = sample_session_with_authority( "sess_order_02", - buyer_pubkey.as_str(), + provider_pubkey, &["sign_event"], true, Some(session_account_id.as_str()), - Some("conn_order_binding_01") - )])) + Some("conn_order_binding_01"), + ); + session["user_pubkey"] = Value::Null; + MockRpcResponse::success(json!([session])) + } + "nip46.get_public_key" => { + assert_eq!(auth_header.as_deref(), Some("Bearer test-token")); + assert_eq!(body["params"]["session_id"], "sess_order_02"); + MockRpcResponse::success(json!({ + "pubkey": buyer_pubkey.as_str() + })) } "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ "deduplicated": false, @@ -1352,6 +1362,11 @@ managed_account_ref = "{account_id}" .iter() .find(|request| request.method == "bridge.order.request") .expect("bridge order request"); + assert!( + recorded_requests + .iter() + .any(|request| request.method == "nip46.get_public_key") + ); assert_eq!(request.body["params"]["signer_session_id"], "sess_order_02"); assert_eq!( request.body["params"]["signer_authority"]["provider_runtime_id"], @@ -1683,7 +1698,7 @@ fn sample_session_with_authority( "role": "remote_signer", "client_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "signer_pubkey": signer_pubkey, - "user_pubkey": Value::Null, + "user_pubkey": signer_pubkey, "relays": ["wss://relay.one"], "permissions": permissions, "auth_required": false,