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