diff --git a/Cargo.toml b/Cargo.toml index 4fbf75d..d5acb54 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,18 +7,20 @@ authors = ["WalletConnect Team"] license = "Apache-2.0" [workspace] -members = ["blockchain_api", "relay_client", "relay_rpc"] +members = ["blockchain_api", "relay_client", "relay_rpc", "sign_api"] [features] default = ["full"] -full = ["client", "rpc", "http"] +full = ["client", "rpc", "http", "sign_api"] client = ["dep:relay_client"] http = ["relay_client/http"] rpc = ["dep:relay_rpc"] +sign_api = ["dep:sign_api"] [dependencies] relay_client = { path = "./relay_client", optional = true } relay_rpc = { path = "./relay_rpc", optional = true } +sign_api = { path = "./sign_api", optional = true } [dev-dependencies] anyhow = "1" diff --git a/relay_client/src/websocket/connection.rs b/relay_client/src/websocket/connection.rs index 20a4d82..370b132 100644 --- a/relay_client/src/websocket/connection.rs +++ b/relay_client/src/websocket/connection.rs @@ -123,7 +123,6 @@ impl Connection { match stream { Some(mut stream) => stream.close(None).await, - None => Err(WebsocketClientError::ClosingFailed(TransportError::AlreadyClosed).into()), } } diff --git a/relay_rpc/Cargo.toml b/relay_rpc/Cargo.toml index c9ff0d7..b0e750a 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -21,6 +21,7 @@ cacao = [ ] [dependencies] +anyhow = "1.0.86" bs58 = "0.4" data-encoding = "2.3" derive_more = { version = "0.99", default-features = false, features = [ @@ -42,6 +43,7 @@ chrono = { version = "0.4", default-features = false, features = [ regex = "1.7" once_cell = "1.16" jsonwebtoken = "8.1" +hkdf = "0.12.4" k256 = { version = "0.13", optional = true } sha3 = { version = "0.10", optional = true } sha2 = { version = "0.10.6" } @@ -56,6 +58,7 @@ alloy-contract = { git = "https://github.com/alloy-rs/alloy.git", rev = "d68a6b7 alloy-json-abi = { version = "0.7.0", optional = true } alloy-sol-types = { version = "0.7.0", optional = true } alloy-primitives = { version = "0.7.0", optional = true } +paste = "1.0.15" strum = { version = "0.26", features = ["strum_macros", "derive"] } [dev-dependencies] diff --git a/relay_rpc/src/rpc.rs b/relay_rpc/src/rpc.rs index 751eaec..54f6602 100644 --- a/relay_rpc/src/rpc.rs +++ b/relay_rpc/src/rpc.rs @@ -3,6 +3,16 @@ use { crate::domain::{DidKey, MessageId, SubscriptionId, Topic}, + params::session::{ + delete::SessionDeleteRequest, + event::SessionEventRequest, + extend::SessionExtendRequest, + propose::SessionProposeRequest, + request::SessionRequestRequest, + settle::SessionSettleRequest, + update::SessionUpdateRequest, + RequestParams, + }, serde::{de::DeserializeOwned, Deserialize, Serialize}, std::{fmt::Debug, sync::Arc}, }; @@ -10,6 +20,7 @@ pub use {error::*, watch::*}; pub mod error; pub mod msg_id; +pub mod params; #[cfg(test)] mod tests; pub mod watch; @@ -88,6 +99,10 @@ impl Payload { Self::Response(response) => response.validate(), } } + + pub fn irn_tag_in_range(tag: u32) -> bool { + (1100..=1115).contains(&tag) + } } impl From for Payload @@ -99,6 +114,18 @@ where } } +impl From for Payload { + fn from(value: Request) -> Self { + Payload::Request(value) + } +} + +impl From for Payload { + fn from(value: Response) -> Self { + Payload::Response(value) + } +} + /// Enum representing a JSON RPC response. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] @@ -195,26 +222,6 @@ impl ErrorResponse { } } -/// Data structure representing error response params. -#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] -pub struct ErrorData { - /// Error code. - pub code: i32, - - /// Error message. - pub message: String, - - /// Error data, if any. - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} - -#[derive(Debug, thiserror::Error, strum::EnumString, strum::IntoStaticStr, PartialEq, Eq)] -pub enum SubscriptionError { - #[error("Subscriber limit exceeded")] - SubscriberLimitExceeded, -} - /// Subscription request parameters. This request does not require the /// subscription to be fully processed, and returns as soon as the server /// receives it. @@ -808,6 +815,37 @@ pub enum Params { /// topic the data is published for. #[serde(rename = "irn_subscription", alias = "iridium_subscription")] Subscription(Subscription), + #[serde(rename = "wc_sessionPropose")] + SessionPropose(SessionProposeRequest), + #[serde(rename = "wc_sessionSettle")] + SessionSettle(SessionSettleRequest), + #[serde(rename = "wc_sessionRequest")] + SessionRequest(SessionRequestRequest), + #[serde(rename = "wc_sessionEvent")] + SessionEvent(SessionEventRequest), + #[serde(rename = "wc_sessionUpdate")] + SessionUpdate(SessionUpdateRequest), + #[serde(rename = "wc_sessionDelete")] + SessionDelete(SessionDeleteRequest), + #[serde(rename = "wc_sessionExtend")] + SessionExtend(SessionExtendRequest), + #[serde(rename = "wc_sessionPing")] + SessionPing(()), +} + +impl From for Params { + fn from(value: RequestParams) -> Self { + match value { + RequestParams::SessionEvent(params) => Params::SessionEvent(params), + RequestParams::SessionSettle(params) => Params::SessionSettle(params), + RequestParams::SessionExtend(params) => Params::SessionExtend(params), + RequestParams::SessionUpdate(params) => Params::SessionUpdate(params), + RequestParams::SessionPropose(params) => Params::SessionPropose(params), + RequestParams::SessionRequest(params) => Params::SessionRequest(params), + RequestParams::SessionDelete(params) => Params::SessionDelete(params), + RequestParams::SessionPing(()) => Params::SessionPing(()), + } + } } /// Data structure representing a JSON RPC request. @@ -844,6 +882,7 @@ impl Request { return Err(PayloadError::InvalidJsonRpcVersion); } + // TODO: add validation checks for Session Params match &self.params { Params::Subscribe(params) => params.validate(), Params::SubscribeBlocking(params) => params.validate(), @@ -858,6 +897,7 @@ impl Request { Params::WatchRegister(params) => params.validate(), Params::WatchUnregister(params) => params.validate(), Params::Subscription(params) => params.validate(), + _ => Ok(()), } } } diff --git a/relay_rpc/src/rpc/error.rs b/relay_rpc/src/rpc/error.rs index d7f5c4c..3c12589 100644 --- a/relay_rpc/src/rpc/error.rs +++ b/relay_rpc/src/rpc/error.rs @@ -1,7 +1,26 @@ -use { - super::ErrorData, - std::fmt::{Debug, Display}, -}; +use std::fmt::{Debug, Display}; +use serde::{Deserialize, Serialize}; + + +/// Data structure representing error response params. +#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)] +pub struct ErrorData { + /// Error code. + pub code: i32, + + /// Error message. + pub message: String, + + /// Error data, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, thiserror::Error, strum::EnumString, strum::IntoStaticStr, PartialEq, Eq)] +pub enum SubscriptionError { + #[error("Subscriber limit exceeded")] + SubscriberLimitExceeded, +} /// Provides serialization to and from string tags. This has a blanket /// implementation for all error types that derive [`strum::EnumString`] and diff --git a/relay_rpc/src/rpc/params.rs b/relay_rpc/src/rpc/params.rs new file mode 100644 index 0000000..7fc91d0 --- /dev/null +++ b/relay_rpc/src/rpc/params.rs @@ -0,0 +1,20 @@ +pub mod session; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + pub description: String, + pub url: String, + pub icons: Vec, + pub name: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +pub struct Relay { + pub protocol: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub data: Option, +} diff --git a/relay_rpc/src/rpc/params/session.rs b/relay_rpc/src/rpc/params/session.rs new file mode 100644 index 0000000..29f2bc4 --- /dev/null +++ b/relay_rpc/src/rpc/params/session.rs @@ -0,0 +1,768 @@ +pub mod delete; +pub mod event; +pub mod extend; +pub mod ping; +pub mod propose; +pub mod request; +pub mod settle; +pub mod update; + +use { + crate::rpc::ErrorData, delete::SessionDeleteRequest, event::SessionEventRequest, extend::SessionExtendRequest, paste::paste, propose::{SessionProposeRequest, SessionProposeResponse}, regex::Regex, request::SessionRequestRequest, serde::{Deserialize, Serialize}, serde_json::Value, settle::SessionSettleRequest, std::{ + collections::{BTreeMap, BTreeSet}, + ops::Deref, + sync::OnceLock, + }, update::SessionUpdateRequest +}; + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +/// +/// https://chainagnostic.org/CAIPs/caip-2 +/// +/// chain_id: namespace + ":" + reference +/// namespace: [-a-z0-9]{3,8} +/// reference: [-_a-zA-Z0-9]{1,32} +static CAIP2_REGEX: OnceLock = OnceLock::new(); +fn get_caip2_regex() -> &'static Regex { + CAIP2_REGEX.get_or_init(|| { + Regex::new(r"^(?P[-[:alnum:]]{3,8})((?::)(?P[-_[:alnum:]]{1,32}))?$") + .expect("invalid regex: unexpected error") + }) +} + +/// Errors covering namespace validation errors. +/// +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +/// and some additional variants. +#[derive(Debug, thiserror::Error, Eq, PartialEq)] +pub enum ProposeNamespaceError { + #[error("Required chains are not supported: {0}")] + UnsupportedChains(String), + #[error("Chains must not be empty")] + UnsupportedChainsEmpty, + #[error("Chains must be CAIP-2 compliant: {0}")] + UnsupportedChainsCaip2(String), + #[error("Chains must be defined in matching namespace: expected={0}, actual={1}")] + UnsupportedChainsNamespace(String, String), + #[error("Required events are not supported: {0}")] + UnsupportedEvents(String), + #[error("Required methods are not supported: {0}")] + UnsupportedMethods(String), + #[error("Required namespace is not supported: {0}")] + UnsupportedNamespace(String), + #[error("Namespace formatting must match CAIP-2: {0}")] + UnsupportedNamespaceKey(String), +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# +/// proposal-namespace +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProposeNamespace { + pub chains: BTreeSet, + pub methods: BTreeSet, + pub events: BTreeSet, +} + +impl ProposeNamespace { + fn supported(&self, required: &Self) -> Result<(), ProposeNamespaceError> { + let join_err = |required: &BTreeSet, ours: &BTreeSet| -> String { + return required + .difference(ours) + .map(|s| s.as_str()) + .collect::>() + .join(","); + }; + + // validate chains + if !self.chains.is_superset(&required.chains) { + return Err(ProposeNamespaceError::UnsupportedChains(join_err( + &required.chains, + &self.chains, + ))); + } + + // validate methods + if !self.methods.is_superset(&required.methods) { + return Err(ProposeNamespaceError::UnsupportedMethods(join_err( + &required.methods, + &self.methods, + ))); + } + + // validate events + if !self.events.is_superset(&required.events) { + return Err(ProposeNamespaceError::UnsupportedEvents(join_err( + &required.events, + &self.events, + ))); + } + + Ok(()) + } + + pub fn chains_caip2_validate( + &self, + namespace: &str, + reference: Option<&str>, + ) -> Result<(), ProposeNamespaceError> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // namespaces#13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index + match (reference, self.chains.is_empty()) { + (None, true) => return Err(ProposeNamespaceError::UnsupportedChainsEmpty), + (Some(_), true) => return Ok(()), + _ => {} + } + + let caip_regex = get_caip2_regex(); + for chain in self.chains.iter() { + let captures = caip_regex + .captures(chain) + .ok_or_else(|| ProposeNamespaceError::UnsupportedChainsCaip2(chain.to_string()))?; + + let chain_namespace = captures + .name("namespace") + .expect("chain namespace name is missing: unexpected error") + .as_str(); + + if namespace != chain_namespace { + return Err(ProposeNamespaceError::UnsupportedChainsNamespace( + namespace.to_string(), + chain_namespace.to_string(), + )); + } + + let chain_reference = + captures + .name("reference") + .map(|m| m.as_str()) + .ok_or_else(|| { + ProposeNamespaceError::UnsupportedChainsCaip2(namespace.to_string()) + })?; + + if let Some(r) = reference { + if r != chain_reference { + return Err(ProposeNamespaceError::UnsupportedChainsCaip2( + namespace.to_string(), + )); + } + } + } + + Ok(()) + } +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces +#[derive(Debug, Serialize, Eq, PartialEq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct ProposeNamespaces(pub BTreeMap); + +impl Deref for ProposeNamespaces { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl ProposeNamespaces { + /// Ensures that application is compatible with the requester requirements. + /// + /// Implementation must support at least all the elements in `required`. + pub fn supported(&self, required: &ProposeNamespaces) -> Result<(), ProposeNamespaceError> { + if self.is_empty() { + return Err(ProposeNamespaceError::UnsupportedNamespace( + "None supported".to_string(), + )); + } + + if required.is_empty() { + return Ok(()); + } + + for (name, other) in required.iter() { + let ours = self + .get(name) + .ok_or_else(|| ProposeNamespaceError::UnsupportedNamespace(name.to_string()))?; + ours.supported(other)?; + } + + Ok(()) + } + + pub fn caip2_validate(&self) -> Result<(), ProposeNamespaceError> { + let caip_regex = get_caip2_regex(); + for (name, namespace) in self.deref() { + let captures = caip_regex + .captures(name) + .ok_or_else(|| ProposeNamespaceError::UnsupportedNamespaceKey(name.to_string()))?; + + let name = captures + .name("namespace") + .expect("namespace name missing: unexpected error") + .as_str(); + + let reference = captures.name("reference").map(|m| m.as_str()); + + namespace.chains_caip2_validate(name, reference)?; + } + + Ok(()) + } +} + +/// Errors covering Sign API payload parameter conversion issues. +#[derive(Debug, thiserror::Error)] +pub enum ParamsError { + /// Sign API serialization/deserialization issues. + #[error("Failure serializing/deserializing Sign API parameters: {0}")] + Serde(#[from] serde_json::Error), + /// Sign API invalid response tag. + #[error("Response tag={0} does not match any of the Sign API methods")] + ResponseTag(u32), +} + +/// TODO: some validation from `ProposeNamespaces` should be re-used. +/// TODO: caip-10 validation. +/// TODO: named errors like in `ProposeNamespaces`. +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespaces(pub BTreeMap); + +impl Deref for SettleNamespaces { + type Target = BTreeMap; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespace { + pub accounts: BTreeSet, + pub methods: BTreeSet, + pub events: BTreeSet, +} + +/// Relay protocol metadata. +/// +/// https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +pub trait RelayProtocolMetadata { + /// Retrieves IRN relay protocol metadata. + /// + /// Every method must return corresponding IRN metadata. + fn irn_metadata(&self) -> IrnMetadata; +} + +pub trait RelayProtocolHelpers { + type Params; + + /// Converts "unnamed" payload parameters into typed. + /// + /// Example: success and error response payload does not specify the + /// method. Thus the only way to deserialize the data into typed + /// parameters, is to use the tag to determine the response method. + /// + /// This is a convenience method, so that users don't have to deal + /// with the tags directly. + fn irn_try_from_tag(value: Value, tag: u32) -> Result; +} + +/// Relay IRN protocol metadata. +/// +/// https://specs.walletconnect.com/2.0/specs/servers/relay/relay-server-rpc +/// #definitions +#[derive(Debug, Clone, Copy)] +pub struct IrnMetadata { + pub tag: u32, + pub ttl: u64, + pub prompt: bool, +} + +// Convenience macro to de-duplicate implementation for different parameter +// sets. +macro_rules! impl_relay_protocol_metadata { + ($param_type:ty,$meta:ident) => { + paste! { + impl RelayProtocolMetadata for $param_type { + fn irn_metadata(&self) -> IrnMetadata { + match self { + [<$param_type>]::SessionPropose(_) => propose::[], + [<$param_type>]::SessionSettle(_) => settle::[], + [<$param_type>]::SessionRequest(_) => request::[], + [<$param_type>]::SessionUpdate(_) => update::[], + [<$param_type>]::SessionDelete(_) => delete::[], + [<$param_type>]::SessionEvent(_) => event::[], + [<$param_type>]::SessionExtend(_) => extend::[], + [<$param_type>]::SessionPing(_) => ping::[], + } + } + } + } + } +} + +// Convenience macro to de-duplicate implementation for different parameter +// sets. +macro_rules! impl_relay_protocol_helpers { + ($param_type:ty) => { + paste! { + impl RelayProtocolHelpers for $param_type { + type Params = Self; + + fn irn_try_from_tag(value: Value, tag: u32) -> Result { + match tag { + tag if tag == propose::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionPropose(serde_json::from_value(value)?)) + } + tag if tag == settle::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionSettle(serde_json::from_value(value)?)) + } + tag if tag == request::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionRequest(serde_json::from_value(value)?)) + } + tag if tag == delete::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionDelete(serde_json::from_value(value)?)) + } + tag if tag == extend::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionExtend(serde_json::from_value(value)?)) + } + tag if tag == update::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionUpdate(serde_json::from_value(value)?)) + } + tag if tag == event::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionEvent(serde_json::from_value(value)?)) + } + tag if tag == event::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionPing(serde_json::from_value(value)?)) + } + _ => Err(ParamsError::ResponseTag(tag)), + } + } + } + } + }; +} + +/// Sign API request parameters. +/// +/// https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +/// https://specs.walletconnect.com/2.0/specs/clients/sign/data-structures +#[derive(Debug, Serialize, Eq, Deserialize, Clone, PartialEq)] +#[serde(tag = "method", content = "params")] +pub enum RequestParams { + #[serde(rename = "wc_sessionPropose")] + SessionPropose(SessionProposeRequest), + #[serde(rename = "wc_sessionSettle")] + SessionSettle(SessionSettleRequest), + #[serde(rename = "wc_sessionUpdate")] + SessionUpdate(SessionUpdateRequest), + #[serde(rename = "wc_sessionExtend")] + SessionExtend(SessionExtendRequest), + #[serde(rename = "wc_sessionRequest")] + SessionRequest(SessionRequestRequest), + #[serde(rename = "wc_sessionEvent")] + SessionEvent(SessionEventRequest), + #[serde(rename = "wc_sessionDelete")] + SessionDelete(SessionDeleteRequest), + #[serde(rename = "wc_sessionPing")] + SessionPing(()), +} + +impl_relay_protocol_metadata!(RequestParams, request); + +/// https://www.jsonrpc.org/specification#response_object +/// +/// JSON RPC 2.0 response object can either carry success or error data. +/// Please note, that relay protocol metadata is used to disambiguate the +/// response data. +/// +/// For example: +/// `RelayProtocolHelpers::irn_try_from_tag` is used to deserialize an opaque +/// response data into the typed parameters. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResponseParams { + /// A response with a result. + #[serde(rename = "result")] + Success(Value), + + /// A response for a failed request. + #[serde(rename = "error")] + Err(Value), +} + +/// Typed success response parameters. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseParamsSuccess { + SessionPropose(SessionProposeResponse), + SessionSettle(bool), + SessionUpdate(bool), + SessionExtend(bool), + SessionRequest(bool), + SessionEvent(bool), + SessionDelete(bool), + SessionPing(bool) +} +impl_relay_protocol_metadata!(ResponseParamsSuccess, response); +impl_relay_protocol_helpers!(ResponseParamsSuccess); + +impl TryFrom for ResponseParams { + type Error = ParamsError; + + fn try_from(value: ResponseParamsSuccess) -> Result { + Ok(Self::Success(serde_json::to_value(value)?)) + } +} + +/// Typed error response parameters. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseParamsError { + SessionPropose(ErrorData), + SessionSettle(ErrorData), + SessionUpdate(ErrorData), + SessionExtend(ErrorData), + SessionRequest(ErrorData), + SessionEvent(ErrorData), + SessionDelete(ErrorData), + SessionPing(ErrorData), +} +impl_relay_protocol_metadata!(ResponseParamsError, response); +impl_relay_protocol_helpers!(ResponseParamsError); + +impl TryFrom for ResponseParams { + type Error = ParamsError; + + fn try_from(value: ResponseParamsError) -> Result { + Ok(Self::Err(serde_json::to_value(value)?)) + } +} + +#[cfg(test)] +mod tests { + use {super::*, anyhow::Result, serde::de::DeserializeOwned, serde_json}; + + // ======================================================================================================== + // https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + // rejecting-a-session-response + // - validates namespaces match at least all requiredNamespaces + // ======================================================================================================== + + fn test_namespace() -> ProposeNamespace { + let test_vec = vec![ + "0".to_string(), + "1".to_string(), + "2".to_string(), + "3".to_string(), + "4".to_string(), + ]; + ProposeNamespace { + chains: BTreeSet::from_iter(test_vec.clone()), + methods: BTreeSet::from_iter(test_vec.clone()), + events: BTreeSet::from_iter(test_vec.clone()), + } + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 19-proposal-namespaces-may-be-empty + #[test] + fn namespaces_required_empty_success() { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("1".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + assert!(namespaces + .supported(&ProposeNamespaces( + BTreeMap::::new() + )) + .is_ok()) + } + + #[test] + fn namespace_unsupported_chains_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.chains.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedChains("1".to_string())), + ); + + ours.chains.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedChains("1,2".to_string())), + ); + } + + #[test] + fn namespace_unsupported_methods_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.methods.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedMethods("1".to_string())), + ); + + ours.methods.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedMethods("1,2".to_string())), + ); + } + + #[test] + fn namespace_unsupported_events_failure() { + let theirs = test_namespace(); + let mut ours = test_namespace(); + + ours.events.remove("1"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedEvents("1".to_string())), + ); + + ours.events.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(ProposeNamespaceError::UnsupportedEvents("1,2".to_string())), + ); + } + + // ======================================================================================================== + // CAIP-2 TESTS: https://chainagnostic.org/CAIPs/caip-2 + // ======================================================================================================== + #[test] + fn caip2_test_cases() -> Result<(), ProposeNamespaceError> { + let chains = [ + // Ethereum mainnet + "eip155:1", + // Bitcoin mainnet (see https://github.com/bitcoin/bips/blob/master/bip-0122.mediawiki#definition-of-chain-id) + "bip122:000000000019d6689c085ae165831e93", + // Litecoin + "bip122:12a765e31ffd4059bada1e25190f6e98", + // Feathercoin (Litecoin fork) + "bip122:fdbe99b90c90bae7505796461471d89a", + // Cosmos Hub (Tendermint + Cosmos SDK) + "cosmos:cosmoshub-2", + "cosmos:cosmoshub-3", + // Binance chain (Tendermint + Cosmos SDK; see https://dataseed5.defibit.io/genesis) + "cosmos:Binance-Chain-Tigris", + // IOV Mainnet (Tendermint + weave) + "cosmos:iov-mainnet", + // StarkNet Testnet + "starknet:SN_GOERLI", + // Lisk Mainnet (LIP-0009; see https://github.com/LiskHQ/lips/blob/master/proposals/lip-0009.md) + "lip9:9ee11e9df416b18b", + // Dummy max length (8+1+32 = 41 chars/bytes) + "chainstd:8c3444cf8970a9e41a706fab93e7a6c4", + ]; + + let caip2_regex = get_caip2_regex(); + for chain in chains { + caip2_regex + .captures(chain) + .ok_or_else(|| ProposeNamespaceError::UnsupportedChainsCaip2(chain.to_string()))?; + } + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 12-proposal-namespaces-must-not-have-chains-empty + #[test] + fn caip2_12_chains_empty_failure() { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsEmpty), + ); + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index + #[test] + fn caip2_13_chains_omitted_success() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155:1".to_string(), ProposeNamespace { + ..Default::default() + }); + map + }); + + namespaces.caip2_validate()?; + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 14-chains-must-be-caip-2-compliant + #[test] + fn caip2_14_must_be_compliant_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsCaip2( + "1".to_string() + )), + ); + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 16-all-chains-in-the-namespace-must-contain-the-namespace-prefix + #[test] + fn caip2_16_chain_prefix_success() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["eip155:1".to_string()]), + ..Default::default() + }); + map.insert("bip122".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "bip122:000000000019d6689c085ae165831e93".to_string(), + "bip122:12a765e31ffd4059bada1e25190f6e98".to_string(), + ]), + ..Default::default() + }); + map.insert("cosmos".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "cosmos:cosmoshub-2".to_string(), + "cosmos:cosmoshub-3".to_string(), + "cosmos:Binance-Chain-Tigris".to_string(), + "cosmos:iov-mainnet".to_string(), + ]), + ..Default::default() + }); + map.insert("starknet".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["starknet:SN_GOERLI".to_string()]), + ..Default::default() + }); + map.insert("chainstd".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![ + "chainstd:8c3444cf8970a9e41a706fab93e7a6c4".to_string() + ]), + ..Default::default() + }); + map + }); + + namespaces.caip2_validate()?; + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 16-all-chains-in-the-namespace-must-contain-the-namespace-prefix + #[test] + fn caip2_16_chain_prefix_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["cosmos:1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedChainsNamespace( + "eip155".to_string(), + "cosmos".to_string() + )), + ); + + Ok(()) + } + + /// https://specs.walletconnect.com/2.0/specs/clients/sign/namespaces# + /// 17-namespace-key-must-comply-with-caip-2-specification + #[test] + fn caip2_17_namespace_key_failure() -> Result<(), ProposeNamespaceError> { + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec![":1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedNamespaceKey( + "".to_string() + )), + ); + + let namespaces = ProposeNamespaces({ + let mut map: BTreeMap = BTreeMap::new(); + map.insert("**".to_string(), ProposeNamespace { + chains: BTreeSet::from_iter(vec!["**:1".to_string()]), + ..Default::default() + }); + map + }); + + assert_eq!( + namespaces.caip2_validate(), + Err(ProposeNamespaceError::UnsupportedNamespaceKey( + "**".to_string() + )), + ); + + Ok(()) + } + + /// Trims json of the whitespaces and newlines. + /// + /// Allows to use "pretty json" in unittest, and still get consistent + /// results post serialization/deserialization. + pub fn param_json_trim(json: &str) -> String { + json.chars() + .filter(|c| !c.is_whitespace() && *c != '\n') + .collect::() + } + + /// Tests input json serialization/deserialization into the specified type. + pub fn param_serde_test(json: &str) -> Result<()> + where + T: Serialize + DeserializeOwned, + { + let expected = param_json_trim(json); + let deserialized: T = serde_json::from_str(&expected)?; + let actual = serde_json::to_string(&deserialized)?; + + assert_eq!(expected, actual); + + Ok(()) + } +} diff --git a/relay_rpc/src/rpc/params/session/delete.rs b/relay_rpc/src/rpc/params/session/delete.rs new file mode 100644 index 0000000..d31deac --- /dev/null +++ b/relay_rpc/src/rpc/params/session/delete.rs @@ -0,0 +1,46 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessiondelete + +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1112, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1113, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionDeleteRequest { + pub code: i64, + pub message: String, +} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_session_delete_request() -> Result<()> { + let json = r#" + { + "code": 1675757972688031, + "message": "some message" + } + "#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/event.rs b/relay_rpc/src/rpc/params/session/event.rs new file mode 100644 index 0000000..bb4691d --- /dev/null +++ b/relay_rpc/src/rpc/params/session/event.rs @@ -0,0 +1,61 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionevent + +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1110, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1111, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Event { + name: String, + /// Opaque blockchain RPC data. + /// + /// Parsing is deferred to a higher level, blockchain RPC aware code. + data: serde_json::Value, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionEventRequest { + event: Event, + chain_id: String, +} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_accounts_changed_event() -> Result<()> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_event + let json = r#" + { + "event": { + "name": "accountsChanged", + "data": ["0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb"] + }, + "chainId": "eip155:5" + } + "#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/extend.rs b/relay_rpc/src/rpc/params/session/extend.rs new file mode 100644 index 0000000..cb12200 --- /dev/null +++ b/relay_rpc/src/rpc/params/session/extend.rs @@ -0,0 +1,40 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionextend + +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1106, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1107, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionExtendRequest { + pub expiry: u64, +} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_session_extend_request() -> Result<()> { + let json = r#"{"expiry": 86400}"#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/ping.rs b/relay_rpc/src/rpc/params/session/ping.rs new file mode 100644 index 0000000..a20aac0 --- /dev/null +++ b/relay_rpc/src/rpc/params/session/ping.rs @@ -0,0 +1,37 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionping +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1114, + ttl: 30, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1115, + ttl: 30, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionPingRequest {} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_session_ping_request() -> Result<()> { + let json = r#"{}"#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/propose.rs b/relay_rpc/src/rpc/params/session/propose.rs new file mode 100644 index 0000000..7ef7626 --- /dev/null +++ b/relay_rpc/src/rpc/params/session/propose.rs @@ -0,0 +1,93 @@ +use { + super::{IrnMetadata, ProposeNamespaces}, + crate::rpc::params::{Metadata, Relay}, + serde::{Deserialize, Serialize}, +}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1100, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1101, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, Eq, PartialEq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Proposer { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeRequest { + pub relays: Vec, + pub proposer: Proposer, + pub required_namespaces: ProposeNamespaces, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeResponse { + pub relay: Relay, + pub responder_public_key: String, +} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_session_propose_request() -> Result<()> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_propose + let json = r#" + { + "relays": [ + { + "protocol": "irn" + } + ], + "proposer": { + "publicKey": "a3ad5e26070ddb2809200c6f56e739333512015bceeadbb8ea1731c4c7ddb207", + "metadata": { + "description": "React App for WalletConnect", + "url": "http://localhost:3000", + "icons": [ + "https://avatars.githubusercontent.com/u/37784886" + ], + "name": "React App" + } + }, + "requiredNamespaces": { + "eip155": { + "chains": [ + "eip155:5" + ], + "methods": [ + "eth_sendTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "personal_sign" + ], + "events": [ + "accountsChanged", + "chainChanged" + ] + } + } + } + "#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/request.rs b/relay_rpc/src/rpc/params/session/request.rs new file mode 100644 index 0000000..c48026d --- /dev/null +++ b/relay_rpc/src/rpc/params/session/request.rs @@ -0,0 +1,73 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionrequest + +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1108, + ttl: 300, + prompt: true, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1109, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Request { + pub method: String, + /// Opaque blockchain RPC parameters. + /// + /// Parsing is deferred to a higher level, blockchain RPC aware code. + pub params: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionRequestRequest { + pub request: Request, + pub chain_id: String, +} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_eth_sign_transaction() -> Result<()> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_request + let json = r#" + { + "request": { + "method": "eth_signTransaction", + "params": [ + { + "data": "0x", + "from": "0x1456225dE90927193F7A171E64a600416f96f2C8", + "gasLimit": "0x5208", + "gasPrice": "0xa72c", + "nonce": "0x00", + "to": "0x1456225dE90927193F7A171E64a600416f96f2C8", + "value": "0x00" + } + ] + }, + "chainId": "eip155:5" + } + "#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/settle.rs b/relay_rpc/src/rpc/params/session/settle.rs new file mode 100644 index 0000000..1feaa7b --- /dev/null +++ b/relay_rpc/src/rpc/params/session/settle.rs @@ -0,0 +1,91 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionsettle + +use { + super::{IrnMetadata, SettleNamespaces}, + crate::rpc::params::{Metadata, Relay}, + serde::{Deserialize, Serialize}, +}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1102, + ttl: 300, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1103, + ttl: 300, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Controller { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SessionSettleRequest { + pub relay: Relay, + pub controller: Controller, + pub namespaces: SettleNamespaces, + /// Unix timestamp. + /// + /// Expiry should be between .now() + TTL. + pub expiry: u64, +} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_session_settle_request() -> Result<()> { + // Coppied from `session_propose` and adjusted slightly. + let json = r#" + { + "relay": { + "protocol": "irn" + }, + "controller": { + "publicKey": "a3ad5e26070ddb2809200c6f56e739333512015bceeadbb8ea1731c4c7ddb207", + "metadata": { + "description": "React App for WalletConnect", + "url": "http://localhost:3000", + "icons": [ + "https://avatars.githubusercontent.com/u/37784886" + ], + "name": "React App" + } + }, + "namespaces": { + "eip155": { + "accounts": [ + "eip155:5:0xBA5BA3955463ADcc7aa3E33bbdfb8A68e0933dD8" + ], + "methods": [ + "eth_sendTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "personal_sign" + ], + "events": [ + "accountsChanged", + "chainChanged" + ] + } + }, + "expiry": 1675734962 + } + "#; + + param_serde_test::(json) + } +} diff --git a/relay_rpc/src/rpc/params/session/update.rs b/relay_rpc/src/rpc/params/session/update.rs new file mode 100644 index 0000000..085061e --- /dev/null +++ b/relay_rpc/src/rpc/params/session/update.rs @@ -0,0 +1,64 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionupdate + +use { + super::{IrnMetadata, SettleNamespaces}, + serde::{Deserialize, Serialize}, +}; + +pub(super) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1104, + ttl: 86400, + prompt: false, +}; + +pub(super) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1105, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionUpdateRequest { + pub namespaces: SettleNamespaces, +} + +#[cfg(test)] +mod tests { + use { + super::{super::tests::param_serde_test, *}, + anyhow::Result, + }; + + #[test] + fn test_serde_session_update_request() -> Result<()> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // session-events#session_update + let json = r#" + { + "namespaces": { + "eip155": { + "accounts": [ + "eip155:137:0x1456225dE90927193F7A171E64a600416f96f2C8", + "eip155:5:0x1456225dE90927193F7A171E64a600416f96f2C8" + ], + "methods": [ + "eth_sendTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "personal_sign" + ], + "events": [ + "accountsChanged", + "chainChanged" + ] + } + } + } + "#; + + param_serde_test::(json) + } +} diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml new file mode 100644 index 0000000..5ddf2ec --- /dev/null +++ b/sign_api/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "sign_api" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1.0.86" +clap = { version = "4.4", features = ["derive"] } +base64 = "0.21.2" +chacha20poly1305 = "0.10" +chrono = { version = "0.4", default-features = false, features = [ + "alloc", + "std", +] } +hex = "0.4.2" +hkdf = "0.12.4" +lazy_static = "1.4" +paste = "1.0.15" +regex = "1.10.6" +relay_client = { path = "../relay_client" } +relay_rpc = { path = "../relay_rpc" } +sha2 = { version = "0.10.6" } +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +thiserror = "1.0" +url = "2.3" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +rand = { version = "0.8" } +ethers = "2.0.14" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1.22", features = [ + "rt", + "rt-multi-thread", + "sync", + "macros", +] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +tokio = { version = "1.22", features = ["sync", "macros"] } + +[[example]] +name = "session" diff --git a/sign_api/examples/session.rs b/sign_api/examples/session.rs new file mode 100644 index 0000000..e1e620b --- /dev/null +++ b/sign_api/examples/session.rs @@ -0,0 +1,558 @@ +use { + anyhow::Result, + chrono::Utc, + clap::Parser, + relay_client::{ + error::ClientError, + websocket::{Client, CloseFrame, ConnectionHandler, PublishedMessage}, + ConnectionOptions, + MessageIdGenerator, + }, + relay_rpc::{ + auth::{ed25519_dalek::SigningKey, AuthToken}, + domain::{MessageId, SubscriptionId, Topic}, + rpc::{ + params::{ + session::{ + delete::SessionDeleteRequest, + propose::{SessionProposeRequest, SessionProposeResponse}, + settle::{Controller, SessionSettleRequest}, + IrnMetadata, + ProposeNamespace, + ProposeNamespaces, + RelayProtocolMetadata, + RequestParams, + ResponseParamsSuccess, + SettleNamespace, + SettleNamespaces, + }, + Metadata, + Relay, + }, + Params, + Payload, + Request, + Response, + SuccessfulResponse, + JSON_RPC_VERSION_STR, + }, + }, + sign_api::{ + decode_and_decrypt_type0, + encrypt_and_encode, + EnvelopeType, + Pairing as PairingData, + SessionKey, + }, + std::{ + collections::{BTreeMap, HashMap}, + str::FromStr, + sync::Arc, + time::Duration, + }, + tokio::{ + select, + sync::{ + mpsc::{channel, unbounded_channel, Sender, UnboundedSender}, + Mutex, + }, + }, +}; + +const SUPPORTED_PROTOCOL: &str = "irn"; +const SUPPORTED_METHODS: &[&str] = &[ + "eth_sendTransaction", + "eth_signTransaction", + "eth_sign", + "personal_sign", + "eth_signTypedData", + "eth_signTypedData_v4" +]; +const SUPPORTED_CHAINS: &[&str] = &["eip155:1", "eip155:5"]; +const SUPPORTED_EVENTS: &[&str] = &["chainChanged", "accountsChanged"]; +const SUPPORTED_ACCOUNTS: &[&str] = &["eip155:5:0xBA5BA3955463ADcc7aa3E33bbdfb8A68e0933dD8"]; + +// Establish Session. +#[derive(Parser, Debug)] +#[command(author, version, about, long_about = None)] +struct Arg { + /// Goerli https://react-app.walletconnect.com/ pairing URI. + pairing_uri: String, + + /// Specify WebSocket address. + #[arg(short, long, default_value = "wss://relay.walletconnect.com")] + address: String, + + /// Specify WalletConnect project ID. + #[arg(short, long, default_value = "86e916bcbacee7f98225dde86b697f5b")] + project_id: String, +} + +struct Handler { + name: &'static str, + sender: UnboundedSender, +} + +impl Handler { + fn new(name: &'static str, sender: UnboundedSender) -> Self { + Self { name, sender } + } +} + +impl ConnectionHandler for Handler { + fn connected(&mut self) { + println!("\n[{}] connection open", self.name); + } + + fn disconnected(&mut self, frame: Option>) { + println!("\n[{}] connection closed: frame={frame:?}", self.name); + } + + fn message_received(&mut self, message: PublishedMessage) { + println!( + "\n[{}] inbound message: message_id={} topic={} tag={} message={}", + self.name, message.message_id, message.topic, message.tag, message.message, + ); + + if let Err(e) = self.sender.send(message) { + println!("\n[{}] failed to send the to the receiver: {e}", self.name); + } + } + + fn inbound_error(&mut self, error: ClientError) { + println!("\n[{}] inbound error: {error}", self.name); + } + + fn outbound_error(&mut self, error: ClientError) { + println!("\n[{}] outbound error: {error}", self.name); + } +} + +fn create_conn_opts(address: &str, project_id: &str) -> ConnectionOptions { + let key = SigningKey::generate(&mut rand::thread_rng()); + + let auth = AuthToken::new("http://example.com") + .aud(address) + .ttl(Duration::from_secs(60 * 60)) + .as_jwt(&key) + .unwrap(); + + ConnectionOptions::new(project_id, auth).with_address(address) +} + +fn supported_propose_namespaces() -> ProposeNamespaces { + ProposeNamespaces({ + let mut map = BTreeMap::::new(); + map.insert("eip155".to_string(), ProposeNamespace { + chains: SUPPORTED_CHAINS.iter().map(|c| c.to_string()).collect(), + methods: SUPPORTED_METHODS.iter().map(|m| m.to_string()).collect(), + events: SUPPORTED_EVENTS.iter().map(|e| e.to_string()).collect(), + }); + map + }) +} + +fn supported_settle_namespaces() -> SettleNamespaces { + SettleNamespaces({ + let mut map = BTreeMap::::new(); + map.insert("eip155".to_string(), SettleNamespace { + accounts: SUPPORTED_ACCOUNTS.iter().map(|a| a.to_string()).collect(), + methods: SUPPORTED_METHODS.iter().map(|m| m.to_string()).collect(), + events: SUPPORTED_EVENTS.iter().map(|e| e.to_string()).collect(), + }); + map + }) +} + +fn create_settle_request(responder_public_key: String) -> RequestParams { + RequestParams::SessionSettle(SessionSettleRequest { + relay: Relay { + protocol: SUPPORTED_PROTOCOL.to_string(), + data: None, + }, + controller: Controller { + public_key: responder_public_key.to_string(), + metadata: Metadata { + name: format!("Rust session example: {}", Utc::now()), + icons: vec!["https://www.rust-lang.org/static/images/rust-logo-blk.svg".to_string()], + ..Default::default() + }, + }, + namespaces: supported_settle_namespaces(), + expiry: Utc::now().timestamp() as u64 + 300, // 5 min TTL + }) +} + +fn create_proposal_response(responder_public_key: String) -> ResponseParamsSuccess { + ResponseParamsSuccess::SessionPropose(SessionProposeResponse { + relay: Relay { + protocol: SUPPORTED_PROTOCOL.to_string(), + data: None, + }, + responder_public_key, + }) +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal +async fn process_proposal_request( + context: Arc>, + proposal: SessionProposeRequest, +) -> Result { + supported_propose_namespaces().supported(&proposal.required_namespaces)?; + + let sender_public_key = hex::decode(&proposal.proposer.public_key)? + .as_slice() + .try_into()?; + + let session_key = SessionKey::from_osrng(&sender_public_key)?; + let responder_public_key = hex::encode(session_key.diffie_public_key()); + let session_topic: Topic = session_key.generate_topic().into(); + + { + let mut context = context.lock().await; + let subscription_id = context.client.subscribe(session_topic.clone()).await?; + _ = context.sessions.insert(session_topic.clone(), Session { + session_key, + subscription_id, + }); + + let settle_params = create_settle_request(responder_public_key.clone()); + context + .publish_request(session_topic, settle_params) + .await?; + } + Ok(create_proposal_response(responder_public_key)) +} + +fn process_session_delete_request(delete_params: SessionDeleteRequest) -> ResponseParamsSuccess { + println!( + "\nSession is being terminated reason={}, code={}", + delete_params.message, delete_params.code, + ); + + ResponseParamsSuccess::SessionDelete(true) +} + +async fn process_inbound_request( + context: Arc>, + request: Request, + topic: Topic, +) -> Result<()> { + let mut session_delete_cleanup_required: Option = None; + let response = match request.params { + Params::SessionPropose(proposal) => { + process_proposal_request(context.clone(), proposal).await? + } + Params::SessionRequest(param) => { + // process sign tx request here + let message = param.request.params[0].as_str().unwrap(); + let address = param.request.params[1].as_str().unwrap(); + + // For testing purposes, we'll create a mock signature + let mock_signature = mock_sign(address, message); + let context = context.lock().await; + let response = Response::Success(SuccessfulResponse { + id: request.id, + jsonrpc: JSON_RPC_VERSION_STR.into(), + result: serde_json::to_value(mock_signature).unwrap(), + }); + let payload = serde_json::to_string(&Payload::from(response))?; + println!("\nSending response topic={topic} payload={payload}"); + const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1109, + ttl: 300, + prompt: false, + }; + let _ = context.publish_payload(topic.clone(), IRN_RESPONSE_METADATA, &payload).await; + + return Ok(()); + } + Params::SessionDelete(params) => { + session_delete_cleanup_required = Some(topic.clone()); + process_session_delete_request(params) + } + Params::SessionPing(_) => ResponseParamsSuccess::SessionPing(true), + _ => todo!(), + }; + + let mut context = context.lock().await; + context + .publish_success_response(topic, request.id, response) + .await?; + + // Corner case after the session was closed by the dapp. + if let Some(topic) = session_delete_cleanup_required { + context.session_delete_cleanup(topic).await? + } + + Ok(()) +} + +fn mock_sign(address: &str, message: &str) -> String { + // Remove '0x' prefix if present + let message = message.strip_prefix("0x").unwrap_or(message); + + // In a real implementation, we would sign the message here. + // For mocking purposes, we'll create a deterministic "signature" based on the inputs. + let mock_signature = ethers::utils::keccak256(format!("{:?}{}", address, message).as_bytes()); + format!("0x{}", hex::encode(mock_signature)) +} + +fn process_inbound_response(response: Response) -> Result<()> { + match response { + Response::Success(value) => { + let params = serde_json::from_value::(value.result)?; + match params { + ResponseParamsSuccess::SessionSettle(b) => { + if !b { + anyhow::bail!("Unsuccessful response={params:?}"); + } + + Ok(()) + } + _ => todo!(), + } + } + Response::Error(value) => { + // let params = serde_json::from_value::(value.error)?; + anyhow::bail!("DApp send and error response: {value:?}"); + } + } +} + +async fn process_inbound_message( + context: Arc>, + message: PublishedMessage, +) -> Result<()> { + let plain = { + let context = context.lock().await; + context.peek_sym_key(&message.topic, |key| { + decode_and_decrypt_type0(message.message.as_bytes(), key) + .map_err(|e| anyhow::anyhow!(e)) + })? + }; + + println!("\nPlain payload={plain}"); + let payload: Payload = serde_json::from_str(&plain)?; + + match payload { + Payload::Request(request) => process_inbound_request(context, request, message.topic).await, + Payload::Response(response) => process_inbound_response(response), + } +} + +async fn inbound_handler(context: Arc>, message: PublishedMessage) { + if !Payload::irn_tag_in_range(message.tag) { + println!( + "\ntag={} skip handling, doesn't belong to Sign API", + message.tag + ); + return; + } + + match process_inbound_message(context, message).await { + Ok(_) => println!("\nMessage was successfully handled"), + Err(e) => println!("\nFailed to handle the message={e}"), + } +} + +/// https://specs.walletconnect.com/2.0/specs/clients/core/pairing +#[allow(dead_code)] +struct Pairing { + /// Termination signal for when all sessions have been closed. + terminator: Sender<()>, + /// Pairing topic. + topic: Topic, + /// Pairing subscription id. + subscription_id: SubscriptionId, + /// Pairing symmetric key. + /// + /// https://specs.walletconnect.com/2.0/specs/clients/core/crypto/ + /// crypto-keys#key-algorithms + sym_key: [u8; 32], +} + +/// https://specs.walletconnect.com/2.0/specs/clients/sign/session-proposal +/// +/// New session as the result of successful session proposal. +#[allow(dead_code)] +struct Session { + /// Pairing subscription id. + subscription_id: SubscriptionId, + /// Session symmetric key. + /// + /// https://specs.walletconnect.com/2.0/specs/clients/core/crypto/ + /// crypto-keys#key-algorithms + session_key: SessionKey, +} + +/// WCv2 client context. +struct Context { + /// Relay WS client to send and receive messages. + /// + /// TODO: assumed re-entrant/thread-safe? + client: Client, + pairing: Pairing, + /// All session belonging to `pairing`. + /// + /// Uniquely identified by the topic. + sessions: HashMap, +} + +impl Context { + fn new(client: Client, pairing: Pairing) -> Arc> { + Arc::new(Mutex::new(Self { + client, + pairing, + sessions: HashMap::new(), + })) + } + + /// Provides read access to the symmetric encryption/decryption key. + /// + /// Read lock is held for the duration of the call. + fn peek_sym_key(&self, topic: &Topic, f: F) -> Result + where + F: FnOnce(&[u8; 32]) -> Result, + { + if &self.pairing.topic == topic { + f(&self.pairing.sym_key) + } else { + let session = self + .sessions + .get(topic) + .ok_or_else(|| anyhow::anyhow!("Missing sym key for topic={} ", topic))?; + + f(session.session_key.symmetric_key()) + } + } + + async fn publish_request(&self, topic: Topic, params: RequestParams) -> Result<()> { + let irn_helpers = params.irn_metadata(); + let message_id = MessageIdGenerator::new().next(); + let request = Request::new(message_id, params.into()); + let payload = serde_json::to_string(&Payload::from(request))?; + println!("\nSending request topic={topic} payload={payload}"); + self.publish_payload(topic, irn_helpers, &payload).await + } + + async fn publish_success_response( + &self, + topic: Topic, + id: MessageId, + params: ResponseParamsSuccess, + ) -> Result<()> { + let irn_metadata = params.irn_metadata(); + let response = Response::Success(SuccessfulResponse { + id, + jsonrpc: JSON_RPC_VERSION_STR.into(), + result: serde_json::to_value(params).unwrap(), + }); + let payload = serde_json::to_string(&Payload::from(response))?; + println!("\nSending response topic={topic} payload={payload}"); + self.publish_payload(topic, irn_metadata, &payload).await + } + + async fn publish_payload( + &self, + topic: Topic, + irn_metadata: IrnMetadata, + payload: &str, + ) -> Result<()> { + let encrypted = self.peek_sym_key(&topic, |key| { + encrypt_and_encode(EnvelopeType::Type0, payload, key).map_err(|e| anyhow::anyhow!(e)) + })?; + + println!("\nOutbound encrypted payload={encrypted}"); + + self.client + .publish( + topic, + Arc::from(encrypted), + None, + irn_metadata.tag, + Duration::from_secs(irn_metadata.ttl), + irn_metadata.prompt, + ) + .await?; + + Ok(()) + } + + /// Deletes session identified by the `topic`. + /// + /// When session count reaches zero, unsubscribes from topic and sends + /// termination signal to end the application execution. + /// + /// TODO: should really delete pairing as well: + /// https://specs.walletconnect.com/2.0/specs/clients/core/pairing/ + /// rpc-methods#wc_pairingdelete + async fn session_delete_cleanup(&mut self, topic: Topic) -> Result<()> { + let _session = self + .sessions + .remove(&topic) + .ok_or_else(|| anyhow::anyhow!("Attempt to remove non-existing session"))?; + + self.client.unsubscribe(topic).await?; + + // Un-pair when there are no more session subscriptions. + // TODO: Delete pairing, not just unsubscribe. + if self.sessions.is_empty() { + println!("\nNo active sessions left, terminating the pairing"); + + self.client.unsubscribe(self.pairing.topic.clone()).await?; + + self.pairing.terminator.send(()).await?; + } + + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let args = Arg::parse(); + let pairing = PairingData::from_str(&args.pairing_uri)?; + let topic: Topic = pairing.topic.into(); + let (inbound_sender, mut inbound_receiver) = unbounded_channel(); + let (terminate_sender, mut terminate_receiver) = channel::<()>(1); + + let client = Client::new(Handler::new("example_wallet", inbound_sender)); + client + .connect(&create_conn_opts(&args.address, &args.project_id)) + .await?; + + let subscription_id = client.subscribe(topic.clone()).await?; + println!("\n[client1] subscribed: topic={topic} subscription_id={subscription_id}"); + + let context = Context::new(client, Pairing { + terminator: terminate_sender, + topic, + sym_key: pairing.params.sym_key.as_slice().try_into()?, + subscription_id, + }); + + // Processes inbound messages until termination signal is received. + loop { + let context = context.clone(); + select! { + message = inbound_receiver.recv() => { + match message { + Some(m) => { + tokio::spawn(async move { inbound_handler(context, m).await }); + }, + None => { + break; + } + } + + } + _ = terminate_receiver.recv() => { + terminate_receiver.close(); + inbound_receiver.close(); + } + }; + } + + Ok(()) +} diff --git a/sign_api/src/crypto.rs b/sign_api/src/crypto.rs new file mode 100644 index 0000000..da59750 --- /dev/null +++ b/sign_api/src/crypto.rs @@ -0,0 +1,190 @@ +use { + base64::{prelude::BASE64_STANDARD, DecodeError, Engine}, + chacha20poly1305::{ + aead::{Aead, AeadCore, KeyInit, OsRng, Payload}, + ChaCha20Poly1305, + Nonce, + }, + std::string::FromUtf8Error, +}; + +// https://specs.walletconnect.com/2.0/specs/clients/core/crypto/ +// crypto-envelopes +const TYPE_0: u8 = 0; +const TYPE_1: u8 = 1; +const TYPE_INDEX: usize = 0; +const TYPE_LENGTH: usize = 1; +const INIT_VEC_LEN: usize = 12; +const PUB_KEY_LENGTH: usize = 32; +const SYM_KEY_LENGTH: usize = 32; + +pub type InitVec = [u8; INIT_VEC_LEN]; +pub type SymKey = [u8; SYM_KEY_LENGTH]; +pub type PubKey = [u8; PUB_KEY_LENGTH]; + +/// Payload encoding, decoding, encryption and decryption errors. +#[derive(Debug, thiserror::Error)] +pub enum PayloadError { + #[error("Payload is not base64 encoded")] + Base64Decode(#[from] DecodeError), + #[error("Payload decryption failure: {0}")] + Decryption(String), + #[error("Payload encryption failure: {0}")] + Encryption(String), + #[error("Invalid Initialization Vector length={0}")] + InitVecLen(usize), + #[error("Invalid symmetrical key length={0}")] + SymKeyLen(usize), + #[error("Payload does not fit initialization vector (index: {0}..{1})")] + ParseInitVecLen(usize, usize), + #[error("Payload does not fit sender public key (index: {0}..{1})")] + ParseSenderPublicKeyLen(usize, usize), + #[error("Payload is not a valid JSON encoding")] + PayloadJson(#[from] FromUtf8Error), + #[error("Unsupported envelope type={0}")] + UnsupportedEnvelopeType(u8), + #[error("Unexpected envelope type={0}, expected={1}")] + UnexpectedEnvelopeType(u8, u8), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EnvelopeType<'a> { + Type0, + Type1 { sender_public_key: &'a PubKey }, +} + +/// Non-owning convenient representation of the decoded payload blob. +#[derive(Clone, Debug, PartialEq, Eq)] +struct EncodingParams<'a> { + /// Encrypted payload. + sealed: &'a [u8], + /// Initialization Vector. + init_vec: &'a InitVec, + envelope_type: EnvelopeType<'a>, +} + +impl<'a> EncodingParams<'a> { + fn parse_decoded(data: &'a [u8]) -> Result { + let envelope_type = data[0]; + match envelope_type { + TYPE_0 => { + let init_vec_start_index: usize = TYPE_INDEX + TYPE_LENGTH; + let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN; + let sealed_start_index: usize = init_vec_end_index; + Ok(EncodingParams { + init_vec: data[init_vec_start_index..init_vec_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index) + })?, + sealed: &data[sealed_start_index..], + envelope_type: EnvelopeType::Type0, + }) + } + TYPE_1 => { + let key_start_index: usize = TYPE_INDEX + TYPE_LENGTH; + let key_end_index: usize = key_start_index + PUB_KEY_LENGTH; + let init_vec_start_index: usize = key_end_index; + let init_vec_end_index: usize = init_vec_start_index + INIT_VEC_LEN; + let sealed_start_index: usize = init_vec_end_index; + let init_vec = data[init_vec_start_index..init_vec_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseInitVecLen(init_vec_start_index, init_vec_end_index) + })?; + + Ok(EncodingParams { + envelope_type: EnvelopeType::Type1 { + sender_public_key: data[sealed_start_index..key_end_index] + .try_into() + .map_err(|_| { + PayloadError::ParseSenderPublicKeyLen( + init_vec_start_index, + init_vec_end_index, + ) + })?, + }, + init_vec, + sealed: &data[sealed_start_index..], + }) + } + _ => Err(PayloadError::UnsupportedEnvelopeType(envelope_type)), + } + } +} + +/// Encrypts and encodes the plain-text payload. +/// +/// TODO: RNG as an input +pub fn encrypt_and_encode( + envelope_type: EnvelopeType, + msg: T, + key: &SymKey, +) -> Result +where + T: AsRef<[u8]>, +{ + let payload = Payload { + msg: msg.as_ref(), + aad: &[], + }; + let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng); + + let sealed = encrypt(&nonce, payload, key)?; + Ok(encode( + envelope_type, + sealed.as_slice(), + nonce + .as_slice() + .try_into() + .map_err(|_| PayloadError::InitVecLen(nonce.len()))?, + )) +} + +/// Decodes and decrypts the Type0 envelope payload. +pub fn decode_and_decrypt_type0(msg: T, key: &SymKey) -> Result +where + T: AsRef<[u8]>, +{ + let data = BASE64_STANDARD.decode(msg)?; + let decoded = EncodingParams::parse_decoded(&data)?; + if let EnvelopeType::Type1 { .. } = decoded.envelope_type { + return Err(PayloadError::UnexpectedEnvelopeType(TYPE_1, TYPE_0)); + } + + let payload = Payload { + msg: decoded.sealed, + aad: &[], + }; + let decrypted = decrypt(decoded.init_vec.into(), payload, key)?; + + Ok(String::from_utf8(decrypted)?) +} + +fn encrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result, PayloadError> { + let cipher = ChaCha20Poly1305::new(key.into()); + let sealed = cipher + .encrypt(nonce, payload) + .map_err(|e| PayloadError::Encryption(e.to_string()))?; + + Ok(sealed) +} + +fn encode(envelope_type: EnvelopeType, sealed: &[u8], init_vec: &InitVec) -> String { + match envelope_type { + EnvelopeType::Type0 => { + BASE64_STANDARD.encode([&[TYPE_0], init_vec.as_slice(), sealed].concat()) + } + EnvelopeType::Type1 { sender_public_key } => BASE64_STANDARD + .encode([&[TYPE_1], sender_public_key.as_slice(), init_vec, sealed].concat()), + } +} + +fn decrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result, PayloadError> { + let cipher = ChaCha20Poly1305::new(key.into()); + let unsealed = cipher + .decrypt(nonce, payload) + .map_err(|e| PayloadError::Decryption(e.to_string()))?; + + Ok(unsealed) +} diff --git a/sign_api/src/lib.rs b/sign_api/src/lib.rs new file mode 100644 index 0000000..1b369f5 --- /dev/null +++ b/sign_api/src/lib.rs @@ -0,0 +1,9 @@ +mod crypto; +mod pairing_uri; +mod session_key; + +pub use { + crypto::*, + pairing_uri::{Pairing, PairingParams}, + session_key::*, +}; diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs new file mode 100644 index 0000000..b88338e --- /dev/null +++ b/sign_api/src/pairing_uri.rs @@ -0,0 +1,194 @@ +// topic = "7f6e504bfad60b485450578e05678ed3e8e8c4751d3c6160be17160d63ec90f9" +// version = 2 +// symKey = "587d5484ce2a2a6ee3ba1962fdd7e8588e06200c46823bd18fbd67def96ad303" +// methods = [wc_sessionPropose],[wc_authRequest,wc_authBatchRequest] +// relay = { protocol: "irn", data: "" } +// Required + +// symKey (STRING) = symmetric key used for pairing encryption +// methods (STRING) = comma separated array of inner arrays of methods. Inner +// arrays are grouped by ProtocolType relay-protocol (STRING) = protocol name +// used for relay Optional + +// relay-data (STRING) = hex data payload used for relay +// expiryTimestamp (UINT) = unixr timestamp in seconds - after the timestamp the +// pairing is considered expired, should be generated 5 minutes in the future + +use { + lazy_static::lazy_static, + regex::Regex, + std::{collections::HashMap, str::FromStr}, + thiserror::Error, + url::Url, +}; + +lazy_static! { + static ref TOPIC_VERSION_REGEX: Regex = + Regex::new(r"^(?P[[:word:]-]+)@(?P\d+)$").expect("Failed to compile regex"); +} + +#[derive(PartialEq, Eq)] +pub struct PairingParams { + pub sym_key: Vec, + pub relay_protocol: String, + pub relay_data: Option, + pub expiry_timestamp: Option, +} + +#[derive(PartialEq, Eq)] +pub struct Pairing { + pub topic: String, + pub version: String, + pub params: PairingParams, +} + +impl std::fmt::Debug for Pairing { + /// Debug with key masked. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WCPairingUrl") + .field("topic", &self.topic) + .field("version", &self.version) + .field("relay-protocol", &self.params.relay_protocol) + .field("key", &"***") + .field( + "relay-data", + &self.params.relay_data.as_deref().unwrap_or(""), + ) + .finish() + } +} + +impl FromStr for Pairing { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let url = Url::from_str(s).map_err(|err| ParseError::InvalidData(err.to_string()))?; + if url.scheme() != "wc" { + return Result::Err(ParseError::UnexpectedProtocol(url.scheme().to_owned())); + } + + let (topic, version) = Self::try_topic_and_version_from_path(url.path())?; + let params = Self::try_params_from_url(&url)?; + + Ok(Self { + topic, + version, + params, + }) + } +} + +impl Pairing { + fn try_topic_and_version_from_path(path: &str) -> Result<(String, String), ParseError> { + let caps = TOPIC_VERSION_REGEX + .captures(path) + .ok_or(ParseError::InvalidTopicAndVersion)?; + + let topic = caps + .name("topic") + .ok_or(ParseError::TopicNotFound)? + .as_str() + .to_owned(); + + let version = caps + .name("version") + .ok_or(ParseError::VersionNotFound)? + .as_str() + .to_owned(); + + Ok((topic, version)) + } + + /// Try to parse WalletConnect pairing url + fn try_params_from_url(url: &Url) -> Result { + let mut params = HashMap::new(); + let queries = url.query_pairs(); + + for (key, value) in queries { + let sanitized_key: String = key + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .collect(); + if let Some(existing) = params.insert(sanitized_key.to_string(), value.to_string()) { + return Err(ParseError::UnexpectedParameter(key.into_owned(), existing)); + } + } + + let relay_protocol = params + .remove("relay-protocol") + .ok_or(ParseError::RelayProtocolNotFound)?; + + let sym_key = params + .remove("symKey") + .ok_or(ParseError::KeyNotFound) + .and_then(|key| hex::decode(key).map_err(ParseError::InvalidSymKey))?; + + let relay_data = params.remove("relay-data"); + let expiry_timestamp = params + .remove("expiryTimestamp") + .and_then(|t| t.parse::().ok()); + + if !params.is_empty() { + let (key, value) = params.iter().next().unwrap(); + return Err(ParseError::UnexpectedParameter(key.clone(), value.clone())); + } + + Ok(PairingParams { + relay_protocol, + sym_key, + relay_data, + expiry_timestamp, + }) + } +} + +#[derive(Error, Debug)] +pub enum ParseError { + #[error("Invalid topic and version format")] + InvalidTopicAndVersion, + #[error("Topic not found")] + TopicNotFound, + #[error("Version not found")] + VersionNotFound, + #[error("Relay protocol not found")] + RelayProtocolNotFound, + #[error("Symmetric key not found")] + KeyNotFound, + #[error("Invalid symmetric key: {0}")] + InvalidSymKey(#[from] hex::FromHexError), + #[error("Invalid data: {0}")] + InvalidData(String), + #[error("Unexpected parameter: {0} = {1}")] + UnexpectedParameter(String, String), + #[error("Unexpected protocol: {0}")] + UnexpectedProtocol(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_uri() { + let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?\ + relay-protocol=irn&\ + symKey=7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b"; + + let actual = Pairing { + topic: "c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168".to_owned(), + version: "2".to_owned(), + params: PairingParams { + relay_protocol: "irn".to_owned(), + sym_key: hex::decode( + "7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b", + ) + .unwrap(), + relay_data: None, + expiry_timestamp: None, + }, + }; + let expected = Pairing::from_str(uri).unwrap(); + + assert_eq!(actual, expected); + } +} diff --git a/sign_api/src/session_key.rs b/sign_api/src/session_key.rs new file mode 100644 index 0000000..26a9341 --- /dev/null +++ b/sign_api/src/session_key.rs @@ -0,0 +1,73 @@ +use { + hkdf::Hkdf, + rand::{rngs::OsRng, CryptoRng, RngCore}, + sha2::{Digest, Sha256}, + std::fmt::Debug, + x25519_dalek::{EphemeralSecret, PublicKey}, +}; + +/// Session key and topic derivation errors. +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("Failed to generate symmetric session key: {0}")] + SymKeyGeneration(String), +} + +pub struct SessionKey { + sym_key: [u8; 32], + public_key: PublicKey, +} + +impl std::fmt::Debug for SessionKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionKey") + .field("sym_key", &"*******") + .field("public_key", &self.public_key) + .finish() + } +} + +impl SessionKey { + /// Creates new session key from `osrng`. + pub fn from_osrng(sender_public_key: &[u8; 32]) -> Result { + SessionKey::diffie_hellman(OsRng, sender_public_key) + } + + /// Performs Diffie-Hellman symmetric key derivation. + pub fn diffie_hellman(csprng: T, sender_public_key: &[u8; 32]) -> Result + where + T: RngCore + CryptoRng, + { + let single_use_private_key = EphemeralSecret::random_from_rng(csprng); + let public_key = PublicKey::from(&single_use_private_key); + + let ikm = single_use_private_key.diffie_hellman(&PublicKey::from(*sender_public_key)); + + let mut session_sym_key = Self { + sym_key: [0u8; 32], + public_key, + }; + let hk = Hkdf::::new(None, ikm.as_bytes()); + hk.expand(&[], &mut session_sym_key.sym_key) + .map_err(|e| SessionError::SymKeyGeneration(e.to_string()))?; + + Ok(session_sym_key) + } + + /// Gets symmetic key reference. + pub fn symmetric_key(&self) -> &[u8; 32] { + &self.sym_key + } + + /// Gets "our" public key used in symmetric key derivation. + pub fn diffie_public_key(&self) -> &[u8; 32] { + self.public_key.as_bytes() + } + + /// Generates new session topic. + pub fn generate_topic(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.sym_key); + hex::encode(hasher.finalize()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 03b8a93..b301a91 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,3 +2,5 @@ pub use relay_client as client; #[cfg(feature = "rpc")] pub use relay_rpc as rpc; +#[cfg(feature = "sign_api")] +pub use sign_api;