commit 39c0313aed49b558d8fed339c2b256390d94f2e7
parent 45d65480dfef4667686412bfed68d7bc0af33d5d
Author: triesap <tyson@radroots.org>
Date: Sat, 18 Apr 2026 19:12:29 +0000
app: restore the shared remote signer client seam
- add the remote signer crate back to the mounted app workspace
- parse bunker and discovery signer inputs with explicit requested permissions
- expose pending approval approved session and signing client protocol helpers
- repair consumer-side proof buildability with focused remote signer tests
Diffstat:
7 files changed, 1600 insertions(+), 0 deletions(-)
diff --git a/Cargo.toml b/Cargo.toml
@@ -3,6 +3,7 @@ members = [
"crates/shared/core",
"crates/shared/i18n",
"crates/shared/models",
+ "crates/shared/remote_signer",
"crates/shared/sqlite",
"crates/shared/state",
"crates/shared/sync",
diff --git a/crates/shared/remote_signer/Cargo.toml b/crates/shared/remote_signer/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "radroots_app_remote_signer"
+authors.workspace = true
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+repository.workspace = true
+homepage.workspace = true
+publish = false
+
+[lints]
+workspace = true
+
+[dependencies]
+nostr = { version = "0.44.2", features = ["nip44"] }
+radroots_identity.workspace = true
+radroots_nostr = { path = "../../../../lib/crates/nostr", features = ["client"] }
+radroots_nostr_connect = { path = "../../../../lib/crates/nostr_connect" }
+serde.workspace = true
+serde_json.workspace = true
+tokio = { version = "1.48", features = ["rt", "sync", "time"] }
+url = "2.5"
+
+[dev-dependencies]
+tempfile = "3.23.0"
diff --git a/crates/shared/remote_signer/src/error.rs b/crates/shared/remote_signer/src/error.rs
@@ -0,0 +1,60 @@
+use radroots_nostr_connect::prelude::{RadrootsNostrConnectError, RadrootsNostrConnectMethod};
+use std::fmt;
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsAppRemoteSignerError {
+ EmptyInput,
+ UnsupportedClientUri,
+ MissingDiscoveryUri,
+ InvalidDiscoveryUrl(String),
+ InvalidBunkerUri(String),
+ InvalidSessionStore(String),
+ SessionStoreIo(String),
+ PendingSessionExists,
+ MissingClientSecret,
+ ConnectFailed(String),
+ RequestTimedOut {
+ method: RadrootsNostrConnectMethod,
+ },
+ UnexpectedResponse {
+ method: RadrootsNostrConnectMethod,
+ response: String,
+ },
+}
+
+impl fmt::Display for RadrootsAppRemoteSignerError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::EmptyInput => f.write_str("enter a bunker or discovery url to continue"),
+ Self::UnsupportedClientUri => f.write_str(
+ "enter a bunker or discovery url from the signer; raw nostrconnect client uris are signer-side only",
+ ),
+ Self::MissingDiscoveryUri => {
+ f.write_str("discovery url does not contain a remote signer uri")
+ }
+ Self::InvalidDiscoveryUrl(reason) => write!(f, "invalid discovery url: {reason}"),
+ Self::InvalidBunkerUri(reason) => write!(f, "invalid remote signer uri: {reason}"),
+ Self::InvalidSessionStore(reason) => write!(f, "invalid remote signer store: {reason}"),
+ Self::SessionStoreIo(reason) => write!(f, "remote signer storage failed: {reason}"),
+ Self::PendingSessionExists => {
+ f.write_str("a remote signer connection is already pending approval")
+ }
+ Self::MissingClientSecret => f.write_str("remote signer session secret is missing"),
+ Self::ConnectFailed(reason) => write!(f, "remote signer connection failed: {reason}"),
+ Self::RequestTimedOut { method } => {
+ write!(f, "remote signer request `{method}` timed out")
+ }
+ Self::UnexpectedResponse { method, response } => {
+ write!(f, "remote signer returned an unexpected `{method}` response: {response}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for RadrootsAppRemoteSignerError {}
+
+impl From<RadrootsNostrConnectError> for RadrootsAppRemoteSignerError {
+ fn from(value: RadrootsNostrConnectError) -> Self {
+ Self::InvalidBunkerUri(value.to_string())
+ }
+}
diff --git a/crates/shared/remote_signer/src/input.rs b/crates/shared/remote_signer/src/input.rs
@@ -0,0 +1,182 @@
+use crate::error::RadrootsAppRemoteSignerError;
+use radroots_identity::RadrootsIdentityPublic;
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
+ RadrootsNostrConnectUri,
+};
+use radroots_nostr_connect::uri::RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME;
+use url::Url;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RadrootsAppRemoteSignerSource {
+ BunkerUri,
+ DiscoveryUrl,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsAppRemoteSignerTarget {
+ pub source: RadrootsAppRemoteSignerSource,
+ pub signer_identity: RadrootsIdentityPublic,
+ pub relays: Vec<String>,
+ pub connect_secret: Option<String>,
+ pub requested_permissions: RadrootsNostrConnectPermissions,
+}
+
+impl RadrootsAppRemoteSignerTarget {
+ pub fn source_label(&self) -> &'static str {
+ match self.source {
+ RadrootsAppRemoteSignerSource::BunkerUri => "bunker uri",
+ RadrootsAppRemoteSignerSource::DiscoveryUrl => "discovery url",
+ }
+ }
+
+ pub fn requested_permission_labels(&self) -> Vec<String> {
+ self.requested_permissions
+ .as_slice()
+ .iter()
+ .map(ToString::to_string)
+ .collect()
+ }
+}
+
+pub fn radroots_app_remote_signer_requested_permissions() -> RadrootsNostrConnectPermissions {
+ vec![
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:1",
+ ),
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays),
+ ]
+ .into()
+}
+
+pub fn radroots_app_remote_signer_preview(
+ input: &str,
+) -> Result<RadrootsAppRemoteSignerTarget, RadrootsAppRemoteSignerError> {
+ let trimmed = input.trim();
+ if trimmed.is_empty() {
+ return Err(RadrootsAppRemoteSignerError::EmptyInput);
+ }
+
+ if trimmed.starts_with(&format!("{RADROOTS_NOSTR_CONNECT_BUNKER_URI_SCHEME}://")) {
+ return parse_bunker_uri(trimmed, RadrootsAppRemoteSignerSource::BunkerUri);
+ }
+
+ if trimmed.starts_with("nostrconnect://") {
+ return Err(RadrootsAppRemoteSignerError::UnsupportedClientUri);
+ }
+
+ parse_discovery_url(trimmed)
+}
+
+fn parse_discovery_url(
+ value: &str,
+) -> Result<RadrootsAppRemoteSignerTarget, RadrootsAppRemoteSignerError> {
+ let url = Url::parse(value)
+ .map_err(|error| RadrootsAppRemoteSignerError::InvalidDiscoveryUrl(error.to_string()))?;
+ let Some((_, bunker_uri)) = url.query_pairs().find(|(key, _)| key == "uri") else {
+ return Err(RadrootsAppRemoteSignerError::MissingDiscoveryUri);
+ };
+ parse_bunker_uri(
+ bunker_uri.as_ref(),
+ RadrootsAppRemoteSignerSource::DiscoveryUrl,
+ )
+}
+
+fn parse_bunker_uri(
+ value: &str,
+ source: RadrootsAppRemoteSignerSource,
+) -> Result<RadrootsAppRemoteSignerTarget, RadrootsAppRemoteSignerError> {
+ let uri = RadrootsNostrConnectUri::parse(value)?;
+ let RadrootsNostrConnectUri::Bunker(bunker_uri) = uri else {
+ return Err(RadrootsAppRemoteSignerError::UnsupportedClientUri);
+ };
+ Ok(RadrootsAppRemoteSignerTarget {
+ source,
+ signer_identity: RadrootsIdentityPublic::new(bunker_uri.remote_signer_public_key),
+ relays: bunker_uri
+ .relays
+ .into_iter()
+ .map(|relay| relay.to_string())
+ .collect(),
+ connect_secret: bunker_uri.secret,
+ requested_permissions: radroots_app_remote_signer_requested_permissions(),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_identity::RadrootsIdentity;
+
+ const RELAY_PRIMARY_WSS: &str = "wss://relay.example.com";
+ const SIGNER_SECRET_KEY_HEX: &str =
+ "1111111111111111111111111111111111111111111111111111111111111111";
+
+ fn signer_identity() -> RadrootsIdentity {
+ RadrootsIdentity::from_secret_key_str(SIGNER_SECRET_KEY_HEX).expect("identity")
+ }
+
+ fn bunker_uri() -> String {
+ format!(
+ "bunker://{}?relay={}",
+ signer_identity().public_key_hex(),
+ urlencoding(RELAY_PRIMARY_WSS)
+ )
+ }
+
+ fn discovery_url() -> String {
+ format!(
+ "http://localhost/connect?uri={}",
+ urlencoding(bunker_uri().as_str())
+ )
+ }
+
+ fn urlencoding(value: &str) -> String {
+ url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
+ }
+
+ #[test]
+ fn parses_direct_bunker_uri() {
+ let preview = radroots_app_remote_signer_preview(bunker_uri().as_str()).expect("preview");
+
+ assert_eq!(preview.source, RadrootsAppRemoteSignerSource::BunkerUri);
+ assert_eq!(
+ preview.signer_identity.public_key_hex,
+ signer_identity().public_key_hex()
+ );
+ assert_eq!(preview.relays, vec![RELAY_PRIMARY_WSS.to_owned()]);
+ assert_eq!(preview.connect_secret, None);
+ assert_eq!(
+ preview.requested_permission_labels(),
+ vec!["sign_event:kind:1".to_owned(), "switch_relays".to_owned()]
+ );
+ }
+
+ #[test]
+ fn parses_discovery_url_with_bunker_uri() {
+ let preview =
+ radroots_app_remote_signer_preview(discovery_url().as_str()).expect("preview");
+
+ assert_eq!(preview.source, RadrootsAppRemoteSignerSource::DiscoveryUrl);
+ assert_eq!(
+ preview.signer_identity.public_key_hex,
+ signer_identity().public_key_hex()
+ );
+ assert_eq!(preview.relays, vec![RELAY_PRIMARY_WSS.to_owned()]);
+ assert_eq!(
+ preview.requested_permission_labels(),
+ vec!["sign_event:kind:1".to_owned(), "switch_relays".to_owned()]
+ );
+ }
+
+ #[test]
+ fn rejects_client_side_nostrconnect_uri_input() {
+ let err = radroots_app_remote_signer_preview(
+ "nostrconnect://npub1test?relay=wss%3A%2F%2Frelay.example.com&secret=test",
+ )
+ .expect_err("client uri rejected");
+
+ assert_eq!(err, RadrootsAppRemoteSignerError::UnsupportedClientUri);
+ }
+}
diff --git a/crates/shared/remote_signer/src/lib.rs b/crates/shared/remote_signer/src/lib.rs
@@ -0,0 +1,28 @@
+#![forbid(unsafe_code)]
+
+mod error;
+mod input;
+mod protocol;
+mod session;
+
+pub use error::RadrootsAppRemoteSignerError;
+pub use input::{
+ RadrootsAppRemoteSignerSource, RadrootsAppRemoteSignerTarget,
+ radroots_app_remote_signer_preview, radroots_app_remote_signer_requested_permissions,
+};
+pub use protocol::{
+ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingPollOutcome,
+ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerProgressUpdate,
+ RadrootsAppRemoteSignerSignedEvent, radroots_app_remote_signer_connect_pending,
+ radroots_app_remote_signer_poll_pending_session,
+ radroots_app_remote_signer_poll_pending_session_with_progress,
+ radroots_app_remote_signer_sign_kind1_note,
+ radroots_app_remote_signer_sign_kind1_note_with_progress,
+ radroots_app_remote_signer_sign_unsigned_event,
+ radroots_app_remote_signer_sign_unsigned_event_with_progress,
+};
+pub use session::{
+ RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION, RadrootsAppRemoteSignerSessionRecord,
+ RadrootsAppRemoteSignerSessionStatus, RadrootsAppRemoteSignerSessionStoreLoadResult,
+ RadrootsAppRemoteSignerSessionStoreState,
+};
diff --git a/crates/shared/remote_signer/src/protocol.rs b/crates/shared/remote_signer/src/protocol.rs
@@ -0,0 +1,797 @@
+use crate::error::RadrootsAppRemoteSignerError;
+use crate::input::{RadrootsAppRemoteSignerTarget, radroots_app_remote_signer_preview};
+use crate::session::RadrootsAppRemoteSignerSessionRecord;
+use nostr::JsonUtil;
+use nostr::nips::nip44;
+use nostr::nips::nip44::Version;
+use nostr::{EventBuilder, UnsignedEvent};
+use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
+use radroots_nostr::prelude::{
+ RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrFilter,
+ RadrootsNostrKind, RadrootsNostrRelayPoolNotification, RadrootsNostrTag,
+ RadrootsNostrTimestamp, radroots_nostr_filter_tag, radroots_nostr_kind,
+};
+use radroots_nostr_connect::message::RADROOTS_NOSTR_CONNECT_RPC_KIND;
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPendingConnectionPollOutcome,
+ RadrootsNostrConnectPermissions, RadrootsNostrConnectRequest,
+ RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse,
+ RadrootsNostrConnectResponseEnvelope,
+};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+use tokio::runtime::Builder;
+use tokio::sync::broadcast;
+use tokio::time::timeout;
+
+const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
+const GET_SESSION_CAPABILITY_TIMEOUT: Duration = Duration::from_secs(60);
+const SWITCH_RELAYS_TIMEOUT: Duration = Duration::from_secs(30);
+const SIGN_EVENT_TIMEOUT: Duration = Duration::from_secs(60);
+static REQUEST_COUNTER: AtomicU64 = AtomicU64::new(1);
+
+#[derive(Debug, Clone)]
+pub struct RadrootsAppRemoteSignerPendingSession {
+ pub record: RadrootsAppRemoteSignerSessionRecord,
+ pub client_secret_key_hex: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsAppRemoteSignerApprovedSession {
+ pub user_identity: RadrootsIdentityPublic,
+ pub relays: Vec<String>,
+ pub approved_permissions: RadrootsNostrConnectPermissions,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct RadrootsAppRemoteSignerSignedEvent {
+ pub event_id_hex: String,
+ pub event_json: String,
+ pub relays: Vec<String>,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum RadrootsAppRemoteSignerProgressUpdate {
+ AuthChallenge { url: String },
+}
+
+#[derive(Debug, Clone)]
+pub enum RadrootsAppRemoteSignerPendingPollOutcome {
+ PendingApproval,
+ Approved(RadrootsAppRemoteSignerApprovedSession),
+ TransportFailure { message: String },
+ Rejected { message: String },
+ FatalError { message: String },
+}
+
+pub(crate) struct RadrootsAppRemoteSignerPendingPoller {
+ client: ConnectedRemoteSignerSessionClient,
+}
+
+struct ConnectedRemoteSignerSessionClient {
+ runtime: tokio::runtime::Runtime,
+ client_identity: RadrootsIdentity,
+ target: RadrootsAppRemoteSignerTarget,
+ client: RadrootsNostrClient,
+ notifications: broadcast::Receiver<RadrootsNostrRelayPoolNotification>,
+}
+
+pub fn radroots_app_remote_signer_connect_pending(
+ input: &str,
+) -> Result<RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerError> {
+ let target = radroots_app_remote_signer_preview(input)?;
+ connect_pending_session(target)
+}
+
+pub fn radroots_app_remote_signer_poll_pending_session(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError> {
+ radroots_app_remote_signer_poll_pending_session_with_progress(
+ record,
+ client_secret_key_hex,
+ |_| {},
+ )
+}
+
+pub fn radroots_app_remote_signer_poll_pending_session_with_progress<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ mut progress: F,
+) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError>
+where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+{
+ let mut poller = radroots_app_remote_signer_open_pending_poller(record, client_secret_key_hex)?;
+ radroots_app_remote_signer_poll_pending_poller_with_progress(&mut poller, &mut progress)
+}
+
+pub(crate) fn radroots_app_remote_signer_open_pending_poller(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+) -> Result<RadrootsAppRemoteSignerPendingPoller, RadrootsAppRemoteSignerError> {
+ let client_identity = load_client_identity(client_secret_key_hex)?;
+ let target = target_for_record(record);
+ Ok(RadrootsAppRemoteSignerPendingPoller {
+ client: ConnectedRemoteSignerSessionClient::connect(client_identity, target)?,
+ })
+}
+
+pub(crate) fn radroots_app_remote_signer_poll_pending_poller_with_progress<F>(
+ poller: &mut RadrootsAppRemoteSignerPendingPoller,
+ progress: &mut F,
+) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError>
+where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+{
+ poller.poll_with_progress(progress)
+}
+
+pub fn radroots_app_remote_signer_sign_kind1_note(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ content: &str,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError> {
+ radroots_app_remote_signer_sign_kind1_note_with_progress(
+ record,
+ client_secret_key_hex,
+ content,
+ |_| {},
+ )
+}
+
+pub fn radroots_app_remote_signer_sign_kind1_note_with_progress<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ content: &str,
+ mut progress: F,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
+where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+{
+ sign_kind1_note(record, client_secret_key_hex, content, &mut progress)
+}
+
+pub fn radroots_app_remote_signer_sign_unsigned_event(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ unsigned_event: UnsignedEvent,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError> {
+ radroots_app_remote_signer_sign_unsigned_event_with_progress(
+ record,
+ client_secret_key_hex,
+ unsigned_event,
+ |_| {},
+ )
+}
+
+pub fn radroots_app_remote_signer_sign_unsigned_event_with_progress<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ unsigned_event: UnsignedEvent,
+ mut progress: F,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
+where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+{
+ sign_unsigned_event(record, client_secret_key_hex, unsigned_event, &mut progress)
+}
+
+fn connect_pending_session(
+ target: RadrootsAppRemoteSignerTarget,
+) -> Result<RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerError> {
+ let client_identity = RadrootsIdentity::generate();
+ let connect_request = connect_request_for_target(&target);
+ let response = execute_request(
+ &client_identity,
+ &target,
+ RadrootsNostrConnectMethod::Connect,
+ connect_request,
+ CONNECT_TIMEOUT,
+ )?;
+
+ match response {
+ RadrootsNostrConnectResponse::ConnectAcknowledged
+ | RadrootsNostrConnectResponse::ConnectSecretEcho(_) => {
+ Ok(RadrootsAppRemoteSignerPendingSession {
+ record: RadrootsAppRemoteSignerSessionRecord::pending(
+ client_identity.to_public(),
+ target.signer_identity,
+ target.relays,
+ ),
+ client_secret_key_hex: client_identity.secret_key_hex(),
+ })
+ }
+ other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse {
+ method: RadrootsNostrConnectMethod::Connect,
+ response: format!("{other:?}"),
+ }),
+ }
+}
+
+fn connect_request_for_target(
+ target: &RadrootsAppRemoteSignerTarget,
+) -> RadrootsNostrConnectRequest {
+ RadrootsNostrConnectRequest::Connect {
+ remote_signer_public_key: parse_public_key_hex(
+ target.signer_identity.public_key_hex.as_str(),
+ )
+ .expect("signer public key is derived from a validated identity"),
+ secret: target.connect_secret.clone(),
+ requested_permissions: target.requested_permissions.clone(),
+ }
+}
+
+fn sign_kind1_note<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ content: &str,
+ progress: &mut F,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
+where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+{
+ if !record.allows_sign_event_kind1() {
+ return Err(RadrootsAppRemoteSignerError::ConnectFailed(
+ "remote signer has not approved sign_event:kind:1".to_owned(),
+ ));
+ }
+ let user_identity = record.user_identity.as_ref().ok_or_else(|| {
+ RadrootsAppRemoteSignerError::ConnectFailed(
+ "remote signer session is missing the approved user identity".to_owned(),
+ )
+ })?;
+ let unsigned_event = EventBuilder::text_note(content.trim())
+ .build(parse_public_key_hex(user_identity.public_key_hex.as_str())?);
+ sign_unsigned_event(record, client_secret_key_hex, unsigned_event, progress)
+}
+
+fn sign_unsigned_event<F>(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ client_secret_key_hex: &str,
+ unsigned_event: UnsignedEvent,
+ progress: &mut F,
+) -> Result<RadrootsAppRemoteSignerSignedEvent, RadrootsAppRemoteSignerError>
+where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+{
+ let client_identity = load_client_identity(client_secret_key_hex)?;
+ let target = target_for_record(record);
+ let mut client = ConnectedRemoteSignerSessionClient::connect(client_identity, target)?;
+ let relays = client.sync_relays_if_allowed(record, progress)?;
+ let response = client.execute_request_with_progress(
+ RadrootsNostrConnectMethod::SignEvent,
+ RadrootsNostrConnectRequest::SignEvent(unsigned_event),
+ SIGN_EVENT_TIMEOUT,
+ progress,
+ )?;
+
+ match response {
+ RadrootsNostrConnectResponse::SignedEvent(event) => {
+ Ok(RadrootsAppRemoteSignerSignedEvent {
+ event_id_hex: event.id.to_hex(),
+ event_json: event.as_json(),
+ relays,
+ })
+ }
+ RadrootsNostrConnectResponse::Error { error, .. } => {
+ Err(RadrootsAppRemoteSignerError::ConnectFailed(error))
+ }
+ other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse {
+ method: RadrootsNostrConnectMethod::SignEvent,
+ response: format!("{other:?}"),
+ }),
+ }
+}
+
+fn execute_request(
+ client_identity: &RadrootsIdentity,
+ target: &RadrootsAppRemoteSignerTarget,
+ method: RadrootsNostrConnectMethod,
+ request: RadrootsNostrConnectRequest,
+ request_timeout: Duration,
+) -> Result<RadrootsNostrConnectResponse, RadrootsAppRemoteSignerError> {
+ let mut client =
+ ConnectedRemoteSignerSessionClient::connect(client_identity.clone(), target.clone())?;
+ client.execute_request_with_progress(method, request, request_timeout, &mut |_| {})
+}
+
+impl RadrootsAppRemoteSignerPendingPoller {
+ fn poll_with_progress<F>(
+ &mut self,
+ progress: &mut F,
+ ) -> Result<RadrootsAppRemoteSignerPendingPollOutcome, RadrootsAppRemoteSignerError>
+ where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+ {
+ match self.client.execute_request_with_progress(
+ RadrootsNostrConnectMethod::GetSessionCapability,
+ RadrootsNostrConnectRequest::GetSessionCapability,
+ GET_SESSION_CAPABILITY_TIMEOUT,
+ progress,
+ ) {
+ Ok(response) => Ok(classify_pending_poll_response(response)),
+ Err(error) => Ok(classify_pending_poll_error(error)),
+ }
+ }
+}
+
+impl ConnectedRemoteSignerSessionClient {
+ fn connect(
+ client_identity: RadrootsIdentity,
+ target: RadrootsAppRemoteSignerTarget,
+ ) -> Result<Self, RadrootsAppRemoteSignerError> {
+ let runtime = Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
+ let client = RadrootsNostrClient::from_identity(&client_identity);
+ let notifications = runtime.block_on(async {
+ for relay in &target.relays {
+ client.add_relay(relay).await.map_err(|error| {
+ RadrootsAppRemoteSignerError::ConnectFailed(error.to_string())
+ })?;
+ }
+ client.connect().await;
+ let filter = radroots_nostr_filter_tag(
+ RadrootsNostrFilter::new()
+ .kind(RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND))
+ .since(RadrootsNostrTimestamp::now()),
+ "p",
+ vec![client_identity.public_key_hex()],
+ )
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
+ let notifications = client.notifications();
+ client
+ .subscribe(filter, None)
+ .await
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
+ Ok::<_, RadrootsAppRemoteSignerError>(notifications)
+ })?;
+
+ Ok(Self {
+ runtime,
+ client_identity,
+ target,
+ client,
+ notifications,
+ })
+ }
+
+ fn sync_relays_if_allowed<F>(
+ &mut self,
+ record: &RadrootsAppRemoteSignerSessionRecord,
+ progress: &mut F,
+ ) -> Result<Vec<String>, RadrootsAppRemoteSignerError>
+ where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+ {
+ if !record.allows_switch_relays() {
+ return Ok(self.target.relays.clone());
+ }
+
+ match self.execute_request_with_progress(
+ RadrootsNostrConnectMethod::SwitchRelays,
+ RadrootsNostrConnectRequest::SwitchRelays,
+ SWITCH_RELAYS_TIMEOUT,
+ progress,
+ )? {
+ RadrootsNostrConnectResponse::RelayList(relays) => {
+ let relays: Vec<String> =
+ relays.into_iter().map(|relay| relay.to_string()).collect();
+ self.target.relays = relays.clone();
+ Ok(relays)
+ }
+ RadrootsNostrConnectResponse::RelayListUnchanged => Ok(self.target.relays.clone()),
+ RadrootsNostrConnectResponse::Error { error, .. } => {
+ Err(RadrootsAppRemoteSignerError::ConnectFailed(format!(
+ "remote signer rejected relay update: {error}"
+ )))
+ }
+ other => Err(RadrootsAppRemoteSignerError::UnexpectedResponse {
+ method: RadrootsNostrConnectMethod::SwitchRelays,
+ response: format!("{other:?}"),
+ }),
+ }
+ }
+
+ fn execute_request_with_progress<F>(
+ &mut self,
+ method: RadrootsNostrConnectMethod,
+ request: RadrootsNostrConnectRequest,
+ request_timeout: Duration,
+ progress: &mut F,
+ ) -> Result<RadrootsNostrConnectResponse, RadrootsAppRemoteSignerError>
+ where
+ F: FnMut(RadrootsAppRemoteSignerProgressUpdate),
+ {
+ let request_id = next_request_id(method.to_string().as_str());
+ let response_method = method.clone();
+ self.runtime.block_on(async {
+ let event_builder = build_request_event(
+ &self.client_identity,
+ &self.target.signer_identity,
+ request_id.as_str(),
+ request,
+ )?;
+ self.client
+ .send_event_builder(event_builder)
+ .await
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
+
+ timeout(request_timeout, async {
+ loop {
+ let notification = match self.notifications.recv().await {
+ Ok(notification) => notification,
+ Err(broadcast::error::RecvError::Lagged(_)) => continue,
+ Err(broadcast::error::RecvError::Closed) => {
+ return Err(RadrootsAppRemoteSignerError::ConnectFailed(
+ "remote signer notification stream closed".to_owned(),
+ ));
+ }
+ };
+ let RadrootsNostrRelayPoolNotification::Event { event, .. } = notification
+ else {
+ continue;
+ };
+ let event = *event;
+ if event.kind != RadrootsNostrKind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) {
+ continue;
+ }
+ if event.pubkey.to_hex() != self.target.signer_identity.public_key_hex {
+ continue;
+ }
+ match parse_response_event(
+ &self.client_identity,
+ &event,
+ &response_method,
+ request_id.as_str(),
+ )? {
+ Some(RadrootsNostrConnectResponse::AuthUrl(url)) => {
+ progress(RadrootsAppRemoteSignerProgressUpdate::AuthChallenge { url });
+ }
+ Some(response) => return Ok(response),
+ None => continue,
+ }
+ }
+ })
+ .await
+ .map_err(|_| RadrootsAppRemoteSignerError::RequestTimedOut {
+ method: response_method.clone(),
+ })?
+ })
+ }
+}
+
+fn build_request_event(
+ client_identity: &RadrootsIdentity,
+ signer_identity: &RadrootsIdentityPublic,
+ request_id: &str,
+ request: RadrootsNostrConnectRequest,
+) -> Result<RadrootsNostrEventBuilder, RadrootsAppRemoteSignerError> {
+ let payload = serde_json::to_string(&RadrootsNostrConnectRequestMessage::new(
+ request_id.to_owned(),
+ request,
+ ))
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
+ let signer_public_key = parse_public_key_hex(signer_identity.public_key_hex.as_str())?;
+ let ciphertext = nip44::encrypt(
+ client_identity.keys().secret_key(),
+ &signer_public_key,
+ payload,
+ Version::V2,
+ )
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))?;
+ Ok(RadrootsNostrEventBuilder::new(
+ radroots_nostr_kind(RADROOTS_NOSTR_CONNECT_RPC_KIND),
+ ciphertext,
+ )
+ .tags(vec![RadrootsNostrTag::public_key(signer_public_key)]))
+}
+
+fn parse_response_event(
+ client_identity: &RadrootsIdentity,
+ event: &RadrootsNostrEvent,
+ method: &RadrootsNostrConnectMethod,
+ request_id: &str,
+) -> Result<Option<RadrootsNostrConnectResponse>, RadrootsAppRemoteSignerError> {
+ let decrypted = nip44::decrypt(
+ client_identity.keys().secret_key(),
+ &event.pubkey,
+ &event.content,
+ )
+ .map_err(|error| RadrootsAppRemoteSignerError::UnexpectedResponse {
+ method: method.clone(),
+ response: format!("failed to decrypt signer response: {error}"),
+ })?;
+ let envelope: RadrootsNostrConnectResponseEnvelope =
+ serde_json::from_str(&decrypted).map_err(|error| {
+ RadrootsAppRemoteSignerError::UnexpectedResponse {
+ method: method.clone(),
+ response: format!("failed to decode signer response envelope: {error}"),
+ }
+ })?;
+ if envelope.id != request_id {
+ return Ok(None);
+ }
+ let response =
+ RadrootsNostrConnectResponse::from_envelope(method, envelope).map_err(|error| {
+ RadrootsAppRemoteSignerError::UnexpectedResponse {
+ method: method.clone(),
+ response: format!("failed to decode signer response payload: {error}"),
+ }
+ })?;
+ Ok(Some(response))
+}
+
+fn classify_pending_poll_response(
+ response: RadrootsNostrConnectResponse,
+) -> RadrootsAppRemoteSignerPendingPollOutcome {
+ match response.into_pending_connection_poll_outcome() {
+ RadrootsNostrConnectPendingConnectionPollOutcome::Approved(public_key) => {
+ RadrootsAppRemoteSignerPendingPollOutcome::Approved(
+ RadrootsAppRemoteSignerApprovedSession {
+ user_identity: RadrootsIdentityPublic::new(public_key),
+ relays: Vec::new(),
+ approved_permissions: RadrootsNostrConnectPermissions::default(),
+ },
+ )
+ }
+ RadrootsNostrConnectPendingConnectionPollOutcome::ApprovedCapability(capability) => {
+ RadrootsAppRemoteSignerPendingPollOutcome::Approved(
+ RadrootsAppRemoteSignerApprovedSession {
+ user_identity: RadrootsIdentityPublic::new(capability.user_public_key),
+ relays: capability
+ .relays
+ .into_iter()
+ .map(|relay| relay.to_string())
+ .collect(),
+ approved_permissions: capability.permissions,
+ },
+ )
+ }
+ RadrootsNostrConnectPendingConnectionPollOutcome::PendingApproval => {
+ RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval
+ }
+ RadrootsNostrConnectPendingConnectionPollOutcome::Rejected { message } => {
+ RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }
+ }
+ RadrootsNostrConnectPendingConnectionPollOutcome::AuthChallenge { url } => {
+ RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
+ message: format!("unexpected remote signer authorization challenge: {url}"),
+ }
+ }
+ RadrootsNostrConnectPendingConnectionPollOutcome::UnexpectedResponse { response } => {
+ RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
+ message: format!("unexpected remote signer response: {response}"),
+ }
+ }
+ }
+}
+
+fn classify_pending_poll_error(
+ error: RadrootsAppRemoteSignerError,
+) -> RadrootsAppRemoteSignerPendingPollOutcome {
+ match error {
+ RadrootsAppRemoteSignerError::RequestTimedOut { .. } => {
+ RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure {
+ message: "remote signer did not respond yet".to_owned(),
+ }
+ }
+ RadrootsAppRemoteSignerError::ConnectFailed(message) => {
+ RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { message }
+ }
+ RadrootsAppRemoteSignerError::UnexpectedResponse { .. } => {
+ RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
+ message: error.to_string(),
+ }
+ }
+ other => RadrootsAppRemoteSignerPendingPollOutcome::FatalError {
+ message: other.to_string(),
+ },
+ }
+}
+
+fn next_request_id(prefix: &str) -> String {
+ let tick = REQUEST_COUNTER.fetch_add(1, Ordering::AcqRel);
+ format!("{prefix}-{tick}")
+}
+
+fn parse_public_key_hex(value: &str) -> Result<nostr::PublicKey, RadrootsAppRemoteSignerError> {
+ nostr::PublicKey::parse(value)
+ .or_else(|_| nostr::PublicKey::from_hex(value))
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))
+}
+
+fn load_client_identity(
+ client_secret_key_hex: &str,
+) -> Result<RadrootsIdentity, RadrootsAppRemoteSignerError> {
+ RadrootsIdentity::from_secret_key_str(client_secret_key_hex)
+ .map_err(|error| RadrootsAppRemoteSignerError::ConnectFailed(error.to_string()))
+}
+
+fn target_for_record(
+ record: &RadrootsAppRemoteSignerSessionRecord,
+) -> RadrootsAppRemoteSignerTarget {
+ RadrootsAppRemoteSignerTarget {
+ source: crate::RadrootsAppRemoteSignerSource::BunkerUri,
+ signer_identity: record.signer_identity.clone(),
+ relays: record.relays.clone(),
+ connect_secret: None,
+ requested_permissions: if record.approved_permissions.is_empty() {
+ crate::radroots_app_remote_signer_requested_permissions()
+ } else {
+ record.approved_permissions.clone()
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::radroots_app_remote_signer_preview;
+ use radroots_identity::RadrootsIdentity;
+ use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectPermission, RadrootsNostrConnectRemoteSessionCapability,
+ };
+
+ const RELAY_PRIMARY_WSS: &str = "wss://relay.example.com";
+ const SIGNER_SECRET_KEY_HEX: &str =
+ "1111111111111111111111111111111111111111111111111111111111111111";
+ const CLIENT_SECRET_KEY_HEX: &str =
+ "2222222222222222222222222222222222222222222222222222222222222222";
+
+ fn fixture_identity(secret_key_hex: &str) -> RadrootsIdentity {
+ RadrootsIdentity::from_secret_key_str(secret_key_hex).expect("identity")
+ }
+
+ fn fixture_public_key() -> nostr::PublicKey {
+ fixture_identity(SIGNER_SECRET_KEY_HEX).public_key()
+ }
+
+ fn fixture_discovery_url() -> String {
+ format!(
+ "http://localhost/connect?uri={}",
+ url::form_urlencoded::byte_serialize(
+ format!(
+ "bunker://{}?relay={RELAY_PRIMARY_WSS}",
+ fixture_identity(SIGNER_SECRET_KEY_HEX).public_key_hex()
+ )
+ .as_bytes()
+ )
+ .collect::<String>()
+ )
+ }
+
+ #[test]
+ fn pending_connection_response_is_classified_as_pending_approval() {
+ let outcome =
+ classify_pending_poll_response(RadrootsNostrConnectResponse::PendingConnection);
+
+ assert!(matches!(
+ outcome,
+ RadrootsAppRemoteSignerPendingPollOutcome::PendingApproval
+ ));
+ }
+
+ #[test]
+ fn signer_error_response_is_classified_as_rejected() {
+ let outcome = classify_pending_poll_response(RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "unauthorized".to_owned(),
+ });
+
+ assert!(matches!(
+ outcome,
+ RadrootsAppRemoteSignerPendingPollOutcome::Rejected { message }
+ if message == "unauthorized"
+ ));
+ }
+
+ #[test]
+ fn session_capability_success_is_classified_as_approved() {
+ let outcome =
+ classify_pending_poll_response(RadrootsNostrConnectResponse::RemoteSessionCapability(
+ RadrootsNostrConnectRemoteSessionCapability {
+ user_public_key: fixture_public_key(),
+ relays: vec![nostr::RelayUrl::parse(RELAY_PRIMARY_WSS).expect("relay")],
+ permissions: vec![
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:1",
+ ),
+ RadrootsNostrConnectPermission::new(
+ RadrootsNostrConnectMethod::SwitchRelays,
+ ),
+ ]
+ .into(),
+ },
+ ));
+
+ assert!(matches!(
+ outcome,
+ RadrootsAppRemoteSignerPendingPollOutcome::Approved(
+ RadrootsAppRemoteSignerApprovedSession { user_identity, approved_permissions, .. }
+ ) if user_identity.public_key_hex == fixture_public_key().to_hex()
+ && approved_permissions.to_string() == "sign_event:kind:1,switch_relays"
+ ));
+ }
+
+ #[test]
+ fn timeout_error_is_classified_as_transport_failure() {
+ let outcome = classify_pending_poll_error(RadrootsAppRemoteSignerError::RequestTimedOut {
+ method: RadrootsNostrConnectMethod::GetSessionCapability,
+ });
+
+ assert!(matches!(
+ outcome,
+ RadrootsAppRemoteSignerPendingPollOutcome::TransportFailure { message }
+ if message == "remote signer did not respond yet"
+ ));
+ }
+
+ #[test]
+ fn unexpected_response_error_is_fatal() {
+ let outcome =
+ classify_pending_poll_error(RadrootsAppRemoteSignerError::UnexpectedResponse {
+ method: RadrootsNostrConnectMethod::GetSessionCapability,
+ response: "failed to decode signer response envelope: bad".to_owned(),
+ });
+
+ assert!(matches!(
+ outcome,
+ RadrootsAppRemoteSignerPendingPollOutcome::FatalError { message }
+ if message.contains("unexpected `get_session_capability` response")
+ ));
+ }
+
+ #[test]
+ fn connect_request_uses_explicit_requested_permissions() {
+ let target =
+ radroots_app_remote_signer_preview(fixture_discovery_url().as_str()).expect("preview");
+
+ let request = connect_request_for_target(&target);
+
+ match request {
+ RadrootsNostrConnectRequest::Connect {
+ requested_permissions,
+ ..
+ } => assert_eq!(
+ requested_permissions.to_string(),
+ "sign_event:kind:1,switch_relays"
+ ),
+ other => panic!("unexpected request: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn sign_kind1_note_output_carries_signed_relay_state() {
+ let signed_event = RadrootsAppRemoteSignerSignedEvent {
+ event_id_hex: "deadbeef".to_owned(),
+ event_json: "{\"id\":\"deadbeef\"}".to_owned(),
+ relays: vec!["ws://localhost:8080".to_owned()],
+ };
+
+ assert_eq!(signed_event.event_id_hex, "deadbeef");
+ assert_eq!(signed_event.relays, vec!["ws://localhost:8080".to_owned()]);
+ }
+
+ #[test]
+ fn target_for_record_uses_approved_permissions_when_available() {
+ let client_identity = fixture_identity(CLIENT_SECRET_KEY_HEX).to_public();
+ let signer_identity = fixture_identity(SIGNER_SECRET_KEY_HEX).to_public();
+ let mut record = RadrootsAppRemoteSignerSessionRecord::pending(
+ client_identity,
+ signer_identity,
+ vec![RELAY_PRIMARY_WSS.to_owned()],
+ );
+ record.approved_permissions = vec![RadrootsNostrConnectPermission::new(
+ RadrootsNostrConnectMethod::SwitchRelays,
+ )]
+ .into();
+
+ let target = target_for_record(&record);
+
+ assert_eq!(target.requested_permissions.to_string(), "switch_relays");
+ }
+}
diff --git a/crates/shared/remote_signer/src/session.rs b/crates/shared/remote_signer/src/session.rs
@@ -0,0 +1,506 @@
+use crate::error::RadrootsAppRemoteSignerError;
+use radroots_identity::RadrootsIdentityPublic;
+use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectPermissions,
+};
+use serde::{Deserialize, Serialize};
+use std::io::Write;
+use std::path::Path;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+pub const RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION: u32 = 1;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum RadrootsAppRemoteSignerSessionStatus {
+ PendingApproval,
+ Active,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RadrootsAppRemoteSignerSessionRecord {
+ pub client_identity: RadrootsIdentityPublic,
+ pub signer_identity: RadrootsIdentityPublic,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub user_identity: Option<RadrootsIdentityPublic>,
+ pub relays: Vec<String>,
+ #[serde(default)]
+ pub approved_permissions: RadrootsNostrConnectPermissions,
+ pub status: RadrootsAppRemoteSignerSessionStatus,
+ pub created_at_unix: u64,
+ pub updated_at_unix: u64,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct RadrootsAppRemoteSignerSessionStoreState {
+ pub version: u32,
+ pub sessions: Vec<RadrootsAppRemoteSignerSessionRecord>,
+}
+
+#[derive(Debug, Clone)]
+pub struct RadrootsAppRemoteSignerSessionStoreLoadResult {
+ pub state: RadrootsAppRemoteSignerSessionStoreState,
+ pub recovered_from_corruption: bool,
+}
+
+impl Default for RadrootsAppRemoteSignerSessionStoreState {
+ fn default() -> Self {
+ Self {
+ version: RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION,
+ sessions: Vec::new(),
+ }
+ }
+}
+
+impl RadrootsAppRemoteSignerSessionRecord {
+ pub fn pending(
+ client_identity: RadrootsIdentityPublic,
+ signer_identity: RadrootsIdentityPublic,
+ relays: Vec<String>,
+ ) -> Self {
+ let now = now_unix_secs();
+ Self {
+ client_identity,
+ signer_identity,
+ user_identity: None,
+ relays,
+ approved_permissions: RadrootsNostrConnectPermissions::default(),
+ status: RadrootsAppRemoteSignerSessionStatus::PendingApproval,
+ created_at_unix: now,
+ updated_at_unix: now,
+ }
+ }
+
+ pub fn account_id(&self) -> Option<&str> {
+ self.user_identity
+ .as_ref()
+ .map(|identity| identity.id.as_str())
+ }
+
+ pub fn client_account_id(&self) -> &str {
+ self.client_identity.id.as_str()
+ }
+
+ pub fn approved_permission_labels(&self) -> Vec<String> {
+ self.approved_permissions
+ .as_slice()
+ .iter()
+ .map(ToString::to_string)
+ .collect()
+ }
+
+ pub fn allows_sign_event_kind1(&self) -> bool {
+ self.approved_permissions
+ .as_slice()
+ .iter()
+ .any(|permission| {
+ permission_matches(
+ permission,
+ &RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:1",
+ ),
+ )
+ })
+ }
+
+ pub fn allows_switch_relays(&self) -> bool {
+ self.approved_permissions
+ .as_slice()
+ .iter()
+ .any(|permission| {
+ permission_matches(
+ permission,
+ &RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays),
+ )
+ })
+ }
+}
+
+impl RadrootsAppRemoteSignerSessionStoreState {
+ pub fn load(path: &Path) -> Result<Self, RadrootsAppRemoteSignerError> {
+ Ok(Self::load_with_recovery(path)?.state)
+ }
+
+ pub fn load_with_recovery(
+ path: &Path,
+ ) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, RadrootsAppRemoteSignerError> {
+ match std::fs::read(path) {
+ Ok(contents) => Self::load_bytes(path, contents),
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
+ Ok(RadrootsAppRemoteSignerSessionStoreLoadResult {
+ state: Self::default(),
+ recovered_from_corruption: false,
+ })
+ }
+ Err(error) => Err(RadrootsAppRemoteSignerError::SessionStoreIo(
+ error.to_string(),
+ )),
+ }
+ }
+
+ pub fn save(&self, path: &Path) -> Result<(), RadrootsAppRemoteSignerError> {
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?;
+ }
+ let json = serde_json::to_string_pretty(self)
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?;
+ let temp_path = temporary_store_path(path);
+ let mut file = std::fs::OpenOptions::new()
+ .write(true)
+ .create_new(true)
+ .open(temp_path.as_path())
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?;
+ if let Err(error) = (|| -> Result<(), std::io::Error> {
+ file.write_all(json.as_bytes())?;
+ file.flush()?;
+ file.sync_all()
+ })() {
+ let _ = std::fs::remove_file(temp_path.as_path());
+ return Err(RadrootsAppRemoteSignerError::SessionStoreIo(
+ error.to_string(),
+ ));
+ }
+
+ #[cfg(windows)]
+ if path.exists() {
+ std::fs::remove_file(path)
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))?;
+ }
+
+ std::fs::rename(temp_path.as_path(), path)
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))
+ }
+
+ pub fn pending_session(&self) -> Option<&RadrootsAppRemoteSignerSessionRecord> {
+ self.sessions
+ .iter()
+ .find(|record| record.status == RadrootsAppRemoteSignerSessionStatus::PendingApproval)
+ }
+
+ pub fn active_session_for_account_id(
+ &self,
+ account_id: &str,
+ ) -> Option<&RadrootsAppRemoteSignerSessionRecord> {
+ self.sessions.iter().find(|record| {
+ record.status == RadrootsAppRemoteSignerSessionStatus::Active
+ && record.account_id() == Some(account_id)
+ })
+ }
+
+ pub fn upsert_pending(
+ &mut self,
+ pending: RadrootsAppRemoteSignerSessionRecord,
+ ) -> Result<(), RadrootsAppRemoteSignerError> {
+ if self.pending_session().is_some() {
+ return Err(RadrootsAppRemoteSignerError::PendingSessionExists);
+ }
+ self.sessions
+ .retain(|record| record.client_account_id() != pending.client_account_id());
+ self.sessions.push(pending);
+ Ok(())
+ }
+
+ pub fn activate_session(
+ &mut self,
+ client_account_id: &str,
+ user_identity: RadrootsIdentityPublic,
+ relays: Vec<String>,
+ approved_permissions: RadrootsNostrConnectPermissions,
+ ) -> Option<RadrootsAppRemoteSignerSessionRecord> {
+ let now = now_unix_secs();
+ self.sessions.retain(|record| {
+ !(record.status == RadrootsAppRemoteSignerSessionStatus::Active
+ && record.account_id() == Some(user_identity.id.as_str()))
+ });
+ let record = self
+ .sessions
+ .iter_mut()
+ .find(|record| record.client_account_id() == client_account_id)?;
+ record.user_identity = Some(user_identity);
+ record.relays = relays;
+ record.approved_permissions = approved_permissions;
+ record.status = RadrootsAppRemoteSignerSessionStatus::Active;
+ record.updated_at_unix = now;
+ Some(record.clone())
+ }
+
+ pub fn remove_pending_session(&mut self) -> Option<RadrootsAppRemoteSignerSessionRecord> {
+ let index = self.sessions.iter().position(|record| {
+ record.status == RadrootsAppRemoteSignerSessionStatus::PendingApproval
+ })?;
+ Some(self.sessions.remove(index))
+ }
+
+ pub fn remove_active_session_for_account_id(
+ &mut self,
+ account_id: &str,
+ ) -> Option<RadrootsAppRemoteSignerSessionRecord> {
+ let index = self.sessions.iter().position(|record| {
+ record.status == RadrootsAppRemoteSignerSessionStatus::Active
+ && record.account_id() == Some(account_id)
+ })?;
+ Some(self.sessions.remove(index))
+ }
+
+ fn load_bytes(
+ path: &Path,
+ contents: Vec<u8>,
+ ) -> Result<RadrootsAppRemoteSignerSessionStoreLoadResult, RadrootsAppRemoteSignerError> {
+ let contents = String::from_utf8(contents).map_err(|error| {
+ RadrootsAppRemoteSignerError::InvalidSessionStore(format!(
+ "session store was not valid utf-8: {error}"
+ ))
+ });
+
+ let contents = match contents {
+ Ok(contents) => contents,
+ Err(_) => {
+ quarantine_invalid_store(path)?;
+ return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult {
+ state: Self::default(),
+ recovered_from_corruption: true,
+ });
+ }
+ };
+
+ let state = match serde_json::from_str::<Self>(&contents) {
+ Ok(state) => state,
+ Err(_) => {
+ quarantine_invalid_store(path)?;
+ return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult {
+ state: Self::default(),
+ recovered_from_corruption: true,
+ });
+ }
+ };
+
+ if state.version != RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION {
+ quarantine_invalid_store(path)?;
+ return Ok(RadrootsAppRemoteSignerSessionStoreLoadResult {
+ state: Self::default(),
+ recovered_from_corruption: true,
+ });
+ }
+
+ Ok(RadrootsAppRemoteSignerSessionStoreLoadResult {
+ state,
+ recovered_from_corruption: false,
+ })
+ }
+}
+
+fn permission_matches(
+ granted_permission: &RadrootsNostrConnectPermission,
+ required_permission: &RadrootsNostrConnectPermission,
+) -> bool {
+ if granted_permission.method != required_permission.method {
+ return false;
+ }
+
+ match (
+ &granted_permission.method,
+ granted_permission.parameter.as_deref(),
+ required_permission.parameter.as_deref(),
+ ) {
+ (RadrootsNostrConnectMethod::SignEvent, None, _) => true,
+ (RadrootsNostrConnectMethod::SignEvent, Some(parameter), Some(required)) => {
+ parameter == required || parameter == sign_event_kind_suffix(required)
+ }
+ (_, None, _) => true,
+ (_, Some(parameter), Some(required)) => parameter == required,
+ (_, Some(_), None) => false,
+ }
+}
+
+fn sign_event_kind_suffix(value: &str) -> &str {
+ value.strip_prefix("kind:").unwrap_or(value)
+}
+
+fn now_unix_secs() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_secs())
+ .unwrap_or(0)
+}
+
+fn temporary_store_path(path: &Path) -> std::path::PathBuf {
+ let process_id = std::process::id();
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_nanos())
+ .unwrap_or(0);
+ path.with_extension(format!("json.tmp-{process_id}-{timestamp}"))
+}
+
+fn quarantine_invalid_store(path: &Path) -> Result<(), RadrootsAppRemoteSignerError> {
+ let process_id = std::process::id();
+ let timestamp = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|duration| duration.as_secs())
+ .unwrap_or(0);
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("remote-signer-sessions.json");
+ let quarantine_path =
+ path.with_file_name(format!("{file_name}.corrupt-{timestamp}-{process_id}"));
+ std::fs::rename(path, quarantine_path.as_path())
+ .map_err(|error| RadrootsAppRemoteSignerError::SessionStoreIo(error.to_string()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use radroots_identity::RadrootsIdentity;
+ use radroots_nostr_connect::prelude::{
+ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission,
+ };
+
+ const CLIENT_SECRET_KEY_HEX: &str =
+ "1111111111111111111111111111111111111111111111111111111111111111";
+ const SIGNER_SECRET_KEY_HEX: &str =
+ "2222222222222222222222222222222222222222222222222222222222222222";
+ const USER_SECRET_KEY_HEX: &str =
+ "3333333333333333333333333333333333333333333333333333333333333333";
+
+ fn public_identity(secret_key_hex: &str) -> RadrootsIdentityPublic {
+ RadrootsIdentity::from_secret_key_str(secret_key_hex)
+ .expect("identity")
+ .to_public()
+ }
+
+ fn pending_record() -> RadrootsAppRemoteSignerSessionRecord {
+ RadrootsAppRemoteSignerSessionRecord::pending(
+ public_identity(CLIENT_SECRET_KEY_HEX),
+ public_identity(SIGNER_SECRET_KEY_HEX),
+ vec!["wss://relay.example.com".to_owned()],
+ )
+ }
+
+ #[test]
+ fn pending_store_round_trips() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("sessions.json");
+ let mut state = RadrootsAppRemoteSignerSessionStoreState::default();
+ state.upsert_pending(pending_record()).expect("pending");
+ state.save(path.as_path()).expect("save");
+
+ let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load");
+
+ assert_eq!(loaded.sessions.len(), 1);
+ assert_eq!(
+ loaded.sessions[0].status,
+ RadrootsAppRemoteSignerSessionStatus::PendingApproval
+ );
+ }
+
+ #[test]
+ fn activate_session_replaces_pending_with_active_user_identity() {
+ let mut state = RadrootsAppRemoteSignerSessionStoreState::default();
+ let pending = pending_record();
+ let client_account_id = pending.client_account_id().to_owned();
+ state.upsert_pending(pending).expect("pending");
+
+ let user_public = public_identity(USER_SECRET_KEY_HEX);
+ let active = state
+ .activate_session(
+ client_account_id.as_str(),
+ user_public.clone(),
+ vec!["wss://relay.updated.example".to_owned()],
+ vec![
+ RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "kind:1",
+ ),
+ RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::SwitchRelays),
+ ]
+ .into(),
+ )
+ .expect("active");
+
+ assert_eq!(active.status, RadrootsAppRemoteSignerSessionStatus::Active);
+ assert_eq!(active.account_id(), Some(user_public.id.as_str()));
+ assert_eq!(
+ active.relays,
+ vec!["wss://relay.updated.example".to_owned()]
+ );
+ assert_eq!(
+ active.approved_permission_labels(),
+ vec!["sign_event:kind:1".to_owned(), "switch_relays".to_owned()]
+ );
+ assert!(active.allows_sign_event_kind1());
+ assert!(active.allows_switch_relays());
+ assert!(state.pending_session().is_none());
+ }
+
+ #[test]
+ fn remove_active_session_matches_user_account_id() {
+ let mut state = RadrootsAppRemoteSignerSessionStoreState::default();
+ let pending = pending_record();
+ let client_account_id = pending.client_account_id().to_owned();
+ state.upsert_pending(pending).expect("pending");
+ let user_public = public_identity(USER_SECRET_KEY_HEX);
+ state.activate_session(
+ client_account_id.as_str(),
+ user_public.clone(),
+ vec!["wss://relay.updated.example".to_owned()],
+ RadrootsNostrConnectPermissions::default(),
+ );
+
+ let removed = state
+ .remove_active_session_for_account_id(user_public.id.as_str())
+ .expect("removed");
+
+ assert_eq!(removed.account_id(), Some(user_public.id.as_str()));
+ assert!(state.sessions.is_empty());
+ }
+
+ #[test]
+ fn load_recovers_from_invalid_json_by_quarantining_store() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("sessions.json");
+ std::fs::write(path.as_path(), "{invalid").expect("write invalid");
+
+ let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load");
+
+ assert!(loaded.sessions.is_empty());
+ assert!(!path.exists());
+ let quarantined = std::fs::read_dir(temp.path())
+ .expect("read dir")
+ .filter_map(|entry| entry.ok())
+ .any(|entry| entry.file_name().to_string_lossy().contains("corrupt"));
+ assert!(quarantined);
+ }
+
+ #[test]
+ fn load_recovers_from_unsupported_schema_version() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let path = temp.path().join("sessions.json");
+ std::fs::write(path.as_path(), r#"{"version":999,"sessions":[]}"#).expect("write invalid");
+
+ let loaded = RadrootsAppRemoteSignerSessionStoreState::load(path.as_path()).expect("load");
+
+ assert_eq!(
+ loaded.version,
+ RADROOTS_APP_REMOTE_SIGNER_SESSION_STORE_VERSION
+ );
+ assert!(loaded.sessions.is_empty());
+ assert!(!path.exists());
+ }
+
+ #[test]
+ fn active_session_permission_helpers_respect_sign_event_and_switch_relays() {
+ let mut record = pending_record();
+ record.user_identity = Some(public_identity(USER_SECRET_KEY_HEX));
+ record.status = RadrootsAppRemoteSignerSessionStatus::Active;
+ record.approved_permissions = vec![RadrootsNostrConnectPermission::with_parameter(
+ RadrootsNostrConnectMethod::SignEvent,
+ "1",
+ )]
+ .into();
+
+ assert!(record.allows_sign_event_kind1());
+ assert!(!record.allows_switch_relays());
+ }
+}