app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
MCargo.toml | 1+
Acrates/shared/remote_signer/Cargo.toml | 26++++++++++++++++++++++++++
Acrates/shared/remote_signer/src/error.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/shared/remote_signer/src/input.rs | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/shared/remote_signer/src/lib.rs | 28++++++++++++++++++++++++++++
Acrates/shared/remote_signer/src/protocol.rs | 797+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/shared/remote_signer/src/session.rs | 506+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()); + } +}