diff --git a/.gitignore b/.gitignore index 088ba6b..52e4d31 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,5 @@ Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk + +.idea diff --git a/Cargo.toml b/Cargo.toml index 4fbf75d..5c6f24f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,13 @@ authors = ["WalletConnect Team"] license = "Apache-2.0" [workspace] -members = ["blockchain_api", "relay_client", "relay_rpc"] +members = [ + "blockchain_api", + "wc_common", + "pairing_api", + "relay_client", + "relay_rpc", +] [features] default = ["full"] @@ -15,10 +21,12 @@ full = ["client", "rpc", "http"] client = ["dep:relay_client"] http = ["relay_client/http"] rpc = ["dep:relay_rpc"] +pairing_api = ["dep:pairing_api"] [dependencies] relay_client = { path = "./relay_client", optional = true } relay_rpc = { path = "./relay_rpc", optional = true } +pairing_api = { path = "./pairing_api", optional = true } [dev-dependencies] anyhow = "1" @@ -40,7 +48,7 @@ name = "http_client" required-features = ["client", "rpc", "http"] [[example]] -name = "ws_unmanaged" +name = "websocket_client_with_callback" required-features = ["client", "rpc"] [[example]] diff --git a/examples/websocket_client_with_callback.rs b/examples/websocket_client_with_callback.rs new file mode 100644 index 0000000..061ae46 --- /dev/null +++ b/examples/websocket_client_with_callback.rs @@ -0,0 +1,130 @@ +use { + relay_client::{ + error::ClientError, + websocket::{ + connection_event_loop, + Client, + CloseFrame, + ConnectionHandler, + PublishedMessage, + }, + ConnectionOptions, + }, + relay_rpc::{ + auth::{ed25519_dalek::SigningKey, AuthToken}, + domain::Topic, + }, + std::{sync::Arc, time::Duration}, + structopt::StructOpt, + tokio::spawn, +}; + +#[derive(StructOpt)] +struct Args { + /// Specify WebSocket address. + #[structopt(short, long, default_value = "wss://relay.walletconnect.org")] + address: String, + + /// Specify WalletConnect project ID. + #[structopt(short, long, default_value = "86e916bcbacee7f98225dde86b697f5b")] + project_id: String, +} + +struct Handler { + name: &'static str, +} + +impl Handler { + fn new(name: &'static str) -> Self { + Self { name } + } +} + +impl ConnectionHandler for Handler { + fn connected(&mut self) { + println!("[{}] connection open", self.name); + } + + fn disconnected(&mut self, frame: Option>) { + println!("[{}] connection closed: frame={frame:?}", self.name); + } + + fn message_received(&mut self, message: PublishedMessage) { + println!( + "[{}] inbound message: topic={} message={}", + self.name, message.topic, message.message + ); + } + + fn inbound_error(&mut self, error: ClientError) { + println!("[{}] inbound error: {error}", self.name); + } + + fn outbound_error(&mut self, error: ClientError) { + println!("[{}] 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) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::from_args(); + + let handler = Handler::new("ws_test"); + let (client1, _) = Client::new_with_callback(handler, |rx, handler| { + spawn(connection_event_loop(rx, handler)) + }); + + client1 + .connect(&create_conn_opts(&args.address, &args.project_id)) + .await?; + + let handler = Handler::new("ws_test2"); + let (client2, _) = Client::new_with_callback(handler, |rx, handler| { + spawn(connection_event_loop(rx, handler)) + }); + + client2 + .connect(&create_conn_opts(&args.address, &args.project_id)) + .await?; + + let topic = Topic::generate(); + + let subscription_id = client1.subscribe(topic.clone()).await?; + println!("[client1] subscribed: topic={topic} subscription_id={subscription_id}"); + + client2 + .publish( + topic.clone(), + Arc::from("Hello WalletConnect!"), + None, + 0, + Duration::from_secs(60), + false, + ) + .await?; + + println!("[client2] published message with topic: {topic}",); + + tokio::time::sleep(Duration::from_millis(500)).await; + + drop(client1); + drop(client2); + + tokio::time::sleep(Duration::from_millis(100)).await; + + println!("clients disconnected"); + + Ok(()) +} diff --git a/examples/ws_unmanaged.rs b/examples/ws_unmanaged.rs deleted file mode 100644 index 2a70fd4..0000000 --- a/examples/ws_unmanaged.rs +++ /dev/null @@ -1,140 +0,0 @@ -use { - futures_util::StreamExt, - relay_client::{ - websocket::{Client, Connection, ConnectionControl, PublishedMessage, StreamEvent}, - ConnectionOptions, - }, - relay_rpc::{ - auth::{ed25519_dalek::SigningKey, AuthToken}, - domain::Topic, - }, - std::{sync::Arc, time::Duration}, - structopt::StructOpt, - tokio::spawn, -}; - -#[derive(StructOpt)] -struct Args { - /// Specify WebSocket address. - #[structopt(short, long, default_value = "wss://relay.walletconnect.org")] - address: String, - - /// Specify WalletConnect project ID. - #[structopt(short, long, default_value = "86e916bcbacee7f98225dde86b697f5b")] - project_id: String, -} - -fn create_conn_opts(address: &str, project_id: &str) -> ConnectionOptions { - let key = SigningKey::generate(&mut rand::thread_rng()); - - let auth = AuthToken::new("http://127.0.0.1:8000") - .aud(address) - .ttl(Duration::from_secs(60 * 60)) - .as_jwt(&key) - .unwrap(); - - ConnectionOptions::new(project_id, auth).with_address(address) -} - -async fn client_event_loop(client: Arc) { - let mut conn = Connection::new(); - if let Some(control_rx) = client.control_rx() { - let mut control_rx = control_rx.lock().await; - - loop { - tokio::select! { - event = control_rx.recv() => { - match event { - Some(event) => match event { - ConnectionControl::Connect { request, tx } => { - let result = conn.connect(request).await; - if result.is_ok() { - println!("Client connected"); - } - tx.send(result).ok(); - } - ConnectionControl::Disconnect { tx } => { - tx.send(conn.disconnect().await).ok(); - } - ConnectionControl::OutboundRequest(request) => { - conn.request(request); - } - } - // Control TX has been dropped, shutting down. - None => { - conn.disconnect().await.ok(); - println!("Client disconnected"); - break; - } - } - } - event = conn.select_next_some() => { - match event { - StreamEvent::InboundSubscriptionRequest(request) => { - println!("messaged: received: {:?}", PublishedMessage::from_request(&request)); - request.respond(Ok(true)).ok(); - } - StreamEvent::InboundError(error) => { - println!("Inbound error: {:?}", error); - } - StreamEvent::OutboundError(error) => { - println!("Outbound error: {:?}", error); - } - StreamEvent::ConnectionClosed(frame) => { - println!("connection closed: frame={frame:?}"); - conn.reset(); - } - } - } - } - } - } -} - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = Args::from_args(); - - let client1 = Arc::new(Client::new_unmanaged()); - spawn(client_event_loop(client1.clone())); - - client1 - .connect(&create_conn_opts(&args.address, &args.project_id)) - .await?; - - let client2 = Arc::new(Client::new_unmanaged()); - spawn(client_event_loop(client2.clone())); - - client2 - .connect(&create_conn_opts(&args.address, &args.project_id)) - .await?; - - let topic = Topic::generate(); - - let subscription_id = client1.subscribe(topic.clone()).await?; - println!("[client1] subscribed: topic={topic} subscription_id={subscription_id}"); - - client2 - .publish( - topic.clone(), - Arc::from("Hello WalletConnect!"), - None, - 0, - Duration::from_secs(60), - false, - ) - .await?; - - println!("[client2] published message with topic: {topic}",); - - tokio::time::sleep(Duration::from_millis(500)).await; - - drop(client1); - drop(client2); - - tokio::time::sleep(Duration::from_millis(100)).await; - - println!("clients disconnected"); - - Ok(()) -} diff --git a/pairing_api/Cargo.toml b/pairing_api/Cargo.toml new file mode 100644 index 0000000..db70522 --- /dev/null +++ b/pairing_api/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "pairing_api" +version = "0.1.0" +edition = "2021" + +[features] +example = ["dep:structopt", "dep:tokio", "dep:getrandom"] + +[dependencies] +chrono = { version = "0.4", default-features = false, features = [ + "std", + "clock", +] } +anyhow = "1.0.86" +hex = "0.4.2" +lazy_static = "1.4" +paste = "1.0.15" +rand = "0.8.5" +regex = "1.7" +relay_client = { path = "../relay_client" } +relay_rpc = { path = "../relay_rpc" } +serde_json = "1.0" +serde = { version = "1.0", features = ["derive", "rc"] } +structopt = { version = "0.3", default-features = false, optional = true } +thiserror = "1.0" +url = "2.3" +wc_common = { path = "../wc_common" } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1.22", features = [ + "rt", + "rt-multi-thread", + "sync", + "macros", + "time", + "signal", +], optional = true } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"], optional = true } +tokio = { version = "1.22", features = ["sync", "macros"], optional = true } + +[[example]] +name = "pairing" +required-features = ["example"] diff --git a/pairing_api/examples/pairing.rs b/pairing_api/examples/pairing.rs new file mode 100644 index 0000000..3b7ee6d --- /dev/null +++ b/pairing_api/examples/pairing.rs @@ -0,0 +1,211 @@ +use { + pairing_api::PairingClient, + relay_client::{ + error::ClientError, + websocket::{Client, CloseFrame, ConnectionHandler, PublishedMessage}, + ConnectionOptions, + }, + relay_rpc::{ + auth::{ed25519_dalek::SigningKey, AuthToken}, + domain::Topic, + rpc::{params::ResponseParamsSuccess, Params, Payload}, + }, + std::{sync::Arc, time::Duration}, + structopt::StructOpt, + tokio::{ + signal, + spawn, + sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}, + }, + wc_common::{decode_and_decrypt_type0, SymKey}, +}; + +#[derive(StructOpt)] +struct Args { + /// Specify WebSocket address. + #[structopt(short, long, default_value = "wss://relay.walletconnect.com")] + address: String, + + /// Specify WalletConnect project ID. + #[structopt(short, long, default_value = "1979a8326eb123238e633655924f0a78")] + project_id: String, +} + +struct Handler { + name: &'static str, + sender: UnboundedSender, +} + +fn create_conn_opts(address: &str, project_id: &str) -> ConnectionOptions { + let key = SigningKey::generate(&mut rand::thread_rng()); + + let auth = AuthToken::new("http://127.0.0.1:8000") + .aud(address) + .ttl(Duration::from_secs(60 * 60)) + .as_jwt(&key) + .unwrap(); + + ConnectionOptions::new(project_id, auth).with_address(address) +} + +impl Handler { + fn new(name: &'static str, sender: UnboundedSender) -> Self { + Self { name, sender } + } +} + +impl ConnectionHandler for Handler { + fn connected(&mut self) { + println!("[{}] connection open", self.name); + } + + fn disconnected(&mut self, frame: Option>) { + println!("[{}] connection closed: frame={frame:?}", self.name); + } + + fn message_received(&mut self, message: PublishedMessage) { + println!( + "[{}] inbound message: topic={} message={}", + self.name, message.topic, message.message + ); + + if let Err(err) = self.sender.send(message.clone()) { + println!("error {err:?} while sending {message:?}"); + }; + } + + fn inbound_error(&mut self, error: ClientError) { + println!("[{}] inbound error: {error}", self.name); + } + + fn outbound_error(&mut self, error: ClientError) { + println!("[{}] outbound error: {error}", self.name); + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::from_args(); + let (sender, receiver) = unbounded_channel(); + let client1 = Arc::new(Client::new(Handler::new("client1", sender))); + client1 + .connect(&create_conn_opts(&args.address, &args.project_id)) + .await?; + + let pairing_client = Arc::new(PairingClient::new()); + // Create Pairing. + // let topic = create_pairing(&pairing_client); + // Pair + let topic = connect_to_pairing(&pairing_client, &client1).await; + + // Subscribe to the pairing topic + println!("\nSubscribing to topic: {}", topic); + client1.subscribe(topic.clone()).await?; + println!("\nSuccessfully subscribed to topic: {:?}", topic); + + let key = pairing_client.sym_key(&topic).unwrap(); + let receiver_handle = spawn(spawn_published_message_recv_loop( + client1, + pairing_client, + receiver, + key, + )); + + // Keep the main task running + tokio::select! { + _ = signal::ctrl_c() => { + println!("Received Ctrl+C, shutting down"); + } + _ = receiver_handle => { + println!("Receiver loop ended"); + } + }; + + Ok(()) +} + +async fn spawn_published_message_recv_loop( + client: Arc, + pairing_client: Arc, + mut recv: UnboundedReceiver, + key: SymKey, +) { + while let Some(msg) = recv.recv().await { + let topic = msg.topic; + let message = decode_and_decrypt_type0(msg.message.as_bytes(), &key).unwrap(); + println!("\nInbound message payload={message}"); + + let response = serde_json::from_str::(&message).unwrap(); + match response { + Payload::Request(request) => match request.params { + Params::PairingDelete(_) => { + // send a success response back to wc. + let delete_request = ResponseParamsSuccess::PairingDelete(true); + pairing_client + .publish_response(&topic, delete_request, request.id, &client) + .await + .unwrap(); + // send a request to delete pairing from store. + pairing_client.delete(&topic); + } + Params::PairingExtend(data) => { + let extend_request = ResponseParamsSuccess::PairingExtend(true); + // send a success response back to wc. + pairing_client + .publish_response(&topic, extend_request, request.id, &client) + .await + .unwrap(); + // send a request to update pairing expiry in store. + pairing_client.update_expiry(&topic, data.expiry); + } + Params::PairingPing(_) => { + let ping_request = ResponseParamsSuccess::PairingPing(true); + // send a success response back to wc. + pairing_client + .publish_response(&topic, ping_request, request.id, &client) + .await + .unwrap(); + } + _ => unimplemented!(), + }, + Payload::Response(value) => { + println!("Response: {value:?}"); + } + } + } +} + +/// For a session Controller to pair with a session +async fn connect_to_pairing(pairing_client: &PairingClient, client: &Client) -> Topic { + let topic = pairing_client + .pair( + "wc: + b99c41b1219a6c3131f2960e64cc015900b6880b49470e43bf14e9e520bd922d@2? + expiryTimestamp=1725467415&relay-protocol=irn& + symKey=4a7cccd69a33ac0a3debfbee49e8ff0e65edbdc2031ba600e37880f73eb5b638", + ) + .unwrap(); + client.subscribe(topic.clone()).await.unwrap(); + topic +} + +/// For a Session Proposer to create a pairing connection url. +#[allow(unused)] +fn create_pairing(pairing_client: &PairingClient) -> Topic { + let metadata = relay_rpc::rpc::params::Metadata { + description: "A decentralized application that enables secure + communication and transactions." + .to_string(), + url: "https://127.0.0.1:3000".to_string(), + icons: vec![ + "https://example-dapp.com/icon1.png".to_string(), + "https://example-dapp.com/icon2.png".to_string(), + ], + name: "Example DApp".to_string(), + }; + + let (topic, uri) = pairing_client.create(metadata, None).unwrap(); + println!("pairing_uri: {uri}"); + + topic +} diff --git a/pairing_api/src/lib.rs b/pairing_api/src/lib.rs new file mode 100644 index 0000000..58d03e9 --- /dev/null +++ b/pairing_api/src/lib.rs @@ -0,0 +1,4 @@ +mod pairing; +mod uri; + +pub use {pairing::*, uri::Methods}; diff --git a/pairing_api/src/pairing.rs b/pairing_api/src/pairing.rs new file mode 100644 index 0000000..505bdb7 --- /dev/null +++ b/pairing_api/src/pairing.rs @@ -0,0 +1,469 @@ +use { + crate::{ + uri::{parse_wc_uri, ParseError}, + Methods, + }, + chrono::Utc, + rand::{rngs::OsRng, RngCore}, + relay_client::{websocket::Client, MessageIdGenerator}, + relay_rpc::{ + domain::{MessageId, Topic}, + rpc::{ + params::{ + pairing_delete::PairingDeleteRequest, + pairing_extend::PairingExtendRequest, + pairing_ping::PairingPingRequest, + IrnMetadata, + Metadata, + Relay, + RelayProtocolMetadata, + RequestParams, + ResponseParamsSuccess, + }, + Payload, + PublishError, + Request, + Response, + SubscriptionError, + SuccessfulResponse, + JSON_RPC_VERSION_STR, + }, + }, + serde::{Deserialize, Serialize}, + std::{ + collections::HashMap, + sync::{Arc, RwLock, RwLockReadGuard, RwLockWriteGuard}, + time::Duration, + }, + wc_common::{encrypt_and_encode, EnvelopeType, SymKey}, +}; + +// Duration for short-term expiry (5 minutes) in seconds. +pub(crate) const EXPIRY_5_MINS: u64 = 300; +// Duration for long-term expiry (30 days) in seconds. +pub(crate) const EXPIRY_30_DAYS: u64 = 24 * 30 * 60 * 60; +/// The relay protocol used for WalletConnect communications. +const RELAY_PROTOCOL: &str = "irn"; +/// The version of the WalletConnect protocol. +const VERSION: &str = "2"; +const PAIRING_DELETE_ERROR_CODE: i64 = 6000; + +/// Errors that can occur during pairing operations. +#[derive(Debug, thiserror::Error)] +pub enum PairingClientError { + #[error("Subscription error")] + SubscriptionError(#[from] relay_client::error::Error), + #[error("Topic not found")] + PairingNotFound, + #[error("Pairing with topic already exists")] + PairingTopicAlreadyExists, + #[error("PublishError error")] + PingError(#[from] relay_client::error::Error), + #[error("Encode error")] + EncodeError(String), + #[error("Encode error")] + DecodeError(String), + #[error("Unexpected parameter")] + ParseError(#[from] ParseError), + #[error("Time error")] + TimeError(String), + #[error("InvalidSymKey")] + InvalidSymKey, + #[error("Error generating sym_key")] + GenSymKeyError, +} + +/// Information about a pairing connection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PairingInfo { + /// Topic associated with the pairing. + pub topic: Topic, + /// Relay information used for communication. + pub relay: Relay, + /// Metadata of the peer (if available). + pub peer_metadata: Option, + /// Expiry time of the pairing (in seconds). + pub expiry: u64, + /// Indicates whether the pairing is active. + pub active: bool, + /// Supported methods for the pairing. + pub methods: Methods, +} + +/// Complete pairing including symmetric key and version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pairing { + /// Symmetric key used for encryption. + pub sym_key: SymKey, + /// Version of the pairing protocol. + pub version: String, + /// Information about the pairing connection. + pub info: PairingInfo, +} + +impl Pairing { + pub fn try_from_url(url: &str) -> Result { + let parsed = parse_wc_uri(url)?; + let sym_key = parsed.sym_key; + let expiry = parsed.expiry_timestamp; + let relay = Relay { + protocol: parsed.relay_protocol, + data: parsed.relay_data, + }; + + let info = PairingInfo { + active: false, + methods: parsed.methods, + expiry, + relay, + topic: parsed.topic, + peer_metadata: None, // We don't have peer metadata at this point + }; + + Ok(Pairing { + sym_key, + version: parsed.version, + info, + }) + } +} + +/// A client that manages WalletConnect protocol pairings between wallets +/// and dApps +/// # Examples +/// +/// ```rust +/// use pairing_api::{PairingClient, Methods}; +/// use relay_rpc::rpc::params::Metadata; +/// use pairing_api::PairingClientError; +/// +/// async fn create_pairing() -> Result<(), PairingClientError> { +/// let client = PairingClient::default(); +/// +/// let metadata = Metadata { +/// name: "My dApp".to_string(), +/// description: "A decentralized application".to_string(), +/// icons: vec!["https://my-dapp.com/icon.png".to_string()], +/// url: "https://my-dapp.com".to_string(), +/// }; +/// +/// let methods = Some(Methods(vec![vec![ +/// "eth_signTransaction".to_string(), +/// "personal_sign".to_string(), +/// ]])); +/// +/// let (topic, uri) = client.create(metadata, methods)?; +/// +/// // Share the URI with the responder (e.g., via QR code) +/// println!("Pairing URI: {}", uri); +/// Ok(()) +/// } +#[derive(Debug, Default)] +pub struct PairingClient { + /// Active pairings indexed by their topics. + pub pairings: Arc>>, +} + +impl PairingClient { + /// Initializes pairing client with in-memory storage + pub fn new() -> Self { + Self::default() + } + + fn read(&self) -> RwLockReadGuard> { + self.pairings.read().expect("read failed unexpectedly") + } + + fn write(&self) -> RwLockWriteGuard> { + self.pairings.write().expect("write failed unexpectedly") + } + + /// Get current Unix timestamp in seconds, ensuring it is non-negative. + fn current_timestamp_secs(&self) -> Result { + let now = Utc::now().timestamp(); + if now < 0 { + return Err(PairingClientError::TimeError( + "Negative timestamp".to_string(), + )); + } + + Ok(now as u64) + } + + /// Attempts to generate a new pairing, stores it in the client's pairing + /// list and return the pairing uri and [Topic] + pub fn create( + &self, + metadata: Metadata, + methods: Option, + ) -> Result<(Topic, String), PairingClientError> { + let now = self.current_timestamp_secs()?; + let topic = Topic::generate(); + let relay = Relay { + protocol: RELAY_PROTOCOL.to_owned(), + data: None, + }; + let info = PairingInfo { + active: false, + methods: methods.unwrap_or(Methods(vec![])), + expiry: now + EXPIRY_5_MINS, + relay, + topic: topic.clone(), + peer_metadata: Some(metadata), + }; + + let mut sym_key: SymKey = [0; 32]; + fill_sym_key(&mut sym_key).map_err(|_| PairingClientError::GenSymKeyError)?; + + let uri = Self::generate_uri(&info, &sym_key); + let pairing = Pairing { + sym_key, + version: VERSION.to_owned(), + info, + }; + + self.write().insert(topic.clone(), pairing); + + Ok((topic, uri)) + } + + /// for responder to pair a pairing created by a proposer. + /// NOTE: caller is required to call the [PairingClient::activate] method. + /// On a successful session initialization. + pub fn pair(&self, url: &str) -> Result { + let pairing = Pairing::try_from_url(url)?; + let topic = pairing.info.topic.clone(); + self.write().insert(topic.clone(), pairing); + + Ok(topic) + } + + /// Retrieves the full pairing information for a given topic. + pub fn get_pairing(&self, topic: &Topic) -> Option { + self.read().get(topic).cloned() + } + + /// Retrieves the symmetric key for a given pairing topic. + pub fn sym_key(&self, topic: &Topic) -> Result { + self.get_pairing(topic) + .map(|pairing| pairing.sym_key) + .ok_or(PairingClientError::PairingNotFound) + } + + /// for either to activate a previously created pairing + pub fn activate(&self, topic: &Topic) -> Result<(), PairingClientError> { + if let Some(pairing) = self.write().get_mut(topic) { + let timestamp = self.current_timestamp_secs()?; + pairing.info.active = true; + pairing.info.expiry = timestamp + EXPIRY_30_DAYS; + } + + Err(PairingClientError::PairingNotFound) + } + + /// for either to update the expiry of an existing pairing. + pub fn update_expiry(&self, topic: &Topic, expiry: u64) { + if let Some(pairing) = self.write().get_mut(topic) { + pairing.info.expiry = expiry; + } + } + + /// for either to update the metadata of an existing pairing. + pub fn update_metadata(&self, topic: &Topic, metadata: Metadata) { + if let Some(pairing) = self.write().get_mut(topic) { + pairing.info.peer_metadata = Some(metadata); + } + } + + /// Deletes a pairing from the store and unsubscribe from topic. + /// This should be done only after completing all necessary actions, + /// such as handling responses and requests, since the pairing's sym_key + /// is required for encoding outgoing messages and decoding incoming ones. + pub fn delete(&self, topic: &Topic) { + self.write().remove(topic); + } + + /// Used to evaluate if peer is currently online. Timeout at 30 seconds + /// https://specs.walletconnect.com/2.0/specs/clients/core/pairing/rpc-methods#wc_pairingping + pub async fn ping(&self, topic: &Topic, client: &Client) -> Result<(), PairingClientError> { + let ping_request = RequestParams::PairingPing(PairingPingRequest {}); + self.publish_request(topic, ping_request, client).await?; + + Ok(()) + } + + /// for either peer to disconnect a pairing + pub async fn disconnect_rpc( + &self, + topic: &Topic, + client: &Client, + ) -> Result<(), PairingClientError> { + { + let pairing = self.write().remove(topic); + if pairing.is_some() { + self.publish_request( + topic, + RequestParams::PairingDelete(PairingDeleteRequest { + code: PAIRING_DELETE_ERROR_CODE, + message: "User requested disconnect".to_owned(), + }), + client, + ) + .await?; + }; + } + + client.unsubscribe(topic.clone()).await?; + + Ok(()) + } + + /// Used to update the lifetime of a pairing. + /// https://specs.walletconnect.com/2.0/specs/clients/core/pairing/rpc-methods#wc_pairingextend + pub async fn extend_rpc( + &self, + topic: &Topic, + expiry: u64, + client: &Client, + ) -> Result<(), PairingClientError> { + let extend_request = RequestParams::PairingExtend(PairingExtendRequest { expiry }); + self.publish_request(topic, extend_request, client).await?; + + Ok(()) + } + + /// Private function to publish a request. + async fn publish_request( + &self, + topic: &Topic, + params: RequestParams, + client: &Client, + ) -> Result<(), PairingClientError> { + let irn_metadata = params.irn_metadata(); + let message_id = MessageIdGenerator::new().next(); + let request = Request::new(message_id, params.into()); + self.publish_payload(topic, irn_metadata, Payload::Request(request), client) + .await?; + + Ok(()) + } + + /// Private function to publish a request response. + pub async fn publish_response( + &self, + topic: &Topic, + params: ResponseParamsSuccess, + message_id: MessageId, + client: &Client, + ) -> Result<(), PairingClientError> { + let irn_metadata = params.irn_metadata(); + let response = Response::Success(SuccessfulResponse { + id: message_id, + jsonrpc: JSON_RPC_VERSION_STR.into(), + result: serde_json::to_value(params) + .map_err(|err| PairingClientError::EncodeError(err.to_string()))?, + }); + + self.publish_payload(topic, irn_metadata, Payload::Response(response), client) + .await?; + + Ok(()) + } + + /// Private function to publish a payload. + async fn publish_payload( + &self, + topic: &Topic, + irn_metadata: IrnMetadata, + payload: Payload, + client: &Client, + ) -> Result<(), PairingClientError> { + let sym_key = self.sym_key(topic)?; + let payload = serde_json::to_string(&payload) + .map_err(|err| PairingClientError::EncodeError(err.to_string()))?; + let message = encrypt_and_encode(EnvelopeType::Type0, payload, &sym_key) + .map_err(|err| PairingClientError::EncodeError(err.to_string()))?; + { + client + .publish( + topic.clone(), + message, + None, + irn_metadata.tag, + Duration::from_secs(irn_metadata.ttl), + irn_metadata.prompt, + ) + .await?; + }; + + Ok(()) + } + + /// Private function to generate a WalletConnect URI. + fn generate_uri(pairing: &PairingInfo, sym_key: &SymKey) -> String { + let sym_key = hex::encode(sym_key); + let mut url = format!( + "wc:{}@{}?symKey={}&relay-protocol={}&expiryTimestamp={}", + pairing.topic, VERSION, sym_key, pairing.relay.protocol, pairing.expiry + ); + + if !pairing.methods.0.is_empty() { + let methods_str = pairing + .methods + .0 + .iter() + .map(|method_group| format!("[{}]", method_group.join(","))) + .collect::>() + .join(","); + + url.push_str(&format!("&methods={methods_str}")); + }; + + url + } +} + +#[inline] +fn fill_sym_key(dest: &mut SymKey) -> Result<(), rand::Error> { + OsRng.try_fill_bytes(dest) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_pairing() { + let pairing = Pairing::try_from_url( + "wc:b99c41b1219a6c3131f2960e64cc015900b6880b49470e43bf14e9e520bd922d@2? + expiryTimestamp=1725467415&relay-protocol=irn& + symKey=4a7cccd69a33ac0a3debfbee49e8ff0e65edbdc2031ba600e37880f73eb5b638", + ) + .unwrap(); + + let sym_key = + hex::decode("4a7cccd69a33ac0a3debfbee49e8ff0e65edbdc2031ba600e37880f73eb5b638") + .unwrap() + .try_into() + .unwrap(); + let mut expected = Pairing { + sym_key, + version: "2".to_owned(), + info: PairingInfo { + topic: "b99c41b1219a6c3131f2960e64cc015900b6880b49470e43bf14e9e520bd922d".into(), + relay: Relay { + protocol: "irn".to_owned(), + data: None, + }, + peer_metadata: None, + expiry: 3451086167, + active: false, + methods: Methods(vec![]), + }, + }; + expected.info.expiry = pairing.info.expiry; + + assert_eq!(expected, pairing) + } +} diff --git a/pairing_api/src/uri.rs b/pairing_api/src/uri.rs new file mode 100644 index 0000000..e9f1c92 --- /dev/null +++ b/pairing_api/src/uri.rs @@ -0,0 +1,183 @@ +use { + crate::EXPIRY_5_MINS, + chrono::Utc, + lazy_static::lazy_static, + regex::Regex, + relay_rpc::domain::Topic, + serde::{Deserialize, Serialize}, + std::collections::HashMap, + thiserror::Error, + url::Url, + wc_common::SymKey, +}; + +lazy_static! { + static ref TOPIC_VERSION_REGEX: Regex = + Regex::new(r"^(?P[[:word:]-]+)@(?P\d+)$").expect("Failed to compile regex"); +} + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("Invalid URI")] + InvalidUri, + #[error("Missing topic")] + MissingTopic, + #[error("Invalid version")] + InvalidVersion, + #[error("Missing symmetric key")] + MissingSymKey, + #[error("Invalid symmetric key")] + InvalidSymKey, + #[error("Missing methods")] + MissingMethods, + #[error("Invalid methods format")] + InvalidMethods, + #[error("Missing relay protocol")] + MissingRelayProtocol, + #[error("Invalid expiry timestamp")] + InvalidExpiryTimestamp, + #[error("Unexpected parameter: {0}")] + UnexpectedParameter(String), +} + +#[derive(Debug)] +pub struct ParsedWcUri { + pub topic: Topic, + pub version: String, + pub sym_key: SymKey, + pub methods: Methods, + pub relay_protocol: String, + pub relay_data: Option, + pub expiry_timestamp: u64, +} + +pub fn parse_wc_uri(uri: &str) -> Result { + let url = Url::parse(uri).map_err(|_| ParseError::InvalidUri)?; + + if url.scheme() != "wc" { + return Err(ParseError::InvalidUri); + } + + let (topic, version) = { + let caps = TOPIC_VERSION_REGEX + .captures(url.path().trim()) + .ok_or(ParseError::InvalidUri)?; + let topic = caps + .name("topic") + .ok_or(ParseError::MissingTopic)? + .as_str() + .into(); + let version = caps + .name("version") + .ok_or(ParseError::InvalidVersion)? + .as_str() + .to_owned(); + + (topic, version) + }; + + let mut params = HashMap::new(); + for (key, value) in url.query_pairs() { + params.insert(key.trim().to_string(), value.trim().to_string()); + } + + let methods_str = params.remove("methods"); + let methods = parse_methods(methods_str.as_deref())?; + + let sym_key = params.remove("symKey").ok_or(ParseError::MissingSymKey)?; + let sym_key = hex::decode(sym_key) + .map_err(|_| ParseError::InvalidSymKey)? + .try_into() + .map_err(|_| ParseError::InvalidSymKey)?; + let relay_protocol = params + .remove("relay-protocol") + .ok_or(ParseError::MissingRelayProtocol)?; + let relay_data = params.remove("relay-data"); + + let expiry_timestamp = params.remove("expiryTimestamp").map_or( + Ok(Utc::now().timestamp() as u64 + EXPIRY_5_MINS), + |t| { + t.parse::() + .map_err(|_| ParseError::InvalidExpiryTimestamp) + }, + )?; + + // Check for unexpected parameters + if let Some(unexpected_key) = params.keys().next() { + return Err(ParseError::UnexpectedParameter(unexpected_key.clone())); + } + + Ok(ParsedWcUri { + topic, + version, + sym_key, + methods, + relay_protocol, + relay_data, + expiry_timestamp, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Methods(pub Vec>); + +fn parse_methods(methods_str: Option<&str>) -> Result { + if methods_str.is_none() { + return Ok(Methods(vec![])); + } + + let trimmed = methods_str.unwrap().trim_matches('[').trim_matches(']'); + if trimmed.is_empty() { + return Ok(Methods(vec![])); + } + + let method_groups: Vec> = trimmed + .split("],[") + .map(|group| { + group + .split(',') + .map(|s| s.trim().to_string()) + .collect::>() + }) + .collect(); + + Ok(Methods(method_groups)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_methods() { + // Test the provided example + let input = "[wc_sessionPropose],[wc_authRequest,wc_authBatchRequest]"; + let expected = Methods(vec![vec!["wc_sessionPropose".to_string()], vec![ + "wc_authRequest".to_string(), + "wc_authBatchRequest".to_string(), + ]]); + assert_eq!(parse_methods(Some(input)).unwrap(), expected); + + // Test single method + let input = "[wc_sessionPropose]"; + let expected = Methods(vec![vec!["wc_sessionPropose".to_string()]]); + assert_eq!(parse_methods(Some(input)).unwrap(), expected); + + // Test multiple groups + let input = "[method1,method2],[method3],[method4,method5]"; + let expected = Methods(vec![ + vec!["method1".to_string(), "method2".to_string()], + vec!["method3".to_string()], + vec!["method4".to_string(), "method5".to_string()], + ]); + assert_eq!(parse_methods(Some(input)).unwrap(), expected); + + // Test empty input + let input = "[]"; + assert!(parse_methods(Some(input)).is_ok()); + + // Test empty group + let input = "[method1],[]"; + assert!(parse_methods(Some(input)).is_ok()); + } +} diff --git a/relay_client/Cargo.toml b/relay_client/Cargo.toml index f6f9f86..ca8bc76 100644 --- a/relay_client/Cargo.toml +++ b/relay_client/Cargo.toml @@ -5,8 +5,6 @@ edition = "2021" license = "Apache-2.0" [features] -default = ["tokio-tungstenite-wasm/native-tls"] -rustls = ["tokio-tungstenite-wasm/rustls-tls-native-roots"] http = ["dep:reqwest"] [dependencies] @@ -14,13 +12,14 @@ chrono = { version = "0.4", default-features = false, features = [ "alloc", "std", ] } -data-encoding = "2.6.0" +data-encoding = "2.4.0" futures-util = { version = "0.3", default-features = false, features = [ "sink", "std", ] } http = "1.0.0" pin-project = "1.0" +rand = { version = "0.8.5", features = ["std", "small_rng"] } relay_rpc = { path = "../relay_rpc" } reqwest = { version = "0.12.2", optional = true, features = ["json"] } serde = { version = "1.0", features = ["derive"] } @@ -28,20 +27,12 @@ serde_json = "1.0" serde_qs = "0.10" thiserror = "1.0" tokio = { version = "1.22", features = ["sync", "macros"] } -tokio-tungstenite-wasm = { git = "https://github.com/KomodoPlatform/tokio-tungstenite-wasm.git", rev = "8fc7e2f" } +tokio-tungstenite-wasm = { git = "https://github.com/KomodoPlatform/tokio-tungstenite-wasm.git", features = ["rustls-tls-native-roots"], rev = "8fc7e2f" } tokio-util = "0.7" url = "2.3" -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -rand = { version = "0.7", features = ["std", "small_rng"] } - [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3.27" -rand = { version = "0.7", default-features = false, features = [ - "std", - "small_rng", - "wasm-bindgen", -] } getrandom = { version = "0.2", features = ["js"] } wasm-bindgen = "0.2.86" wasm-bindgen-test = { version = "0.3.2" } diff --git a/relay_client/src/websocket.rs b/relay_client/src/websocket.rs index 06c3008..3e79831 100644 --- a/relay_client/src/websocket.rs +++ b/relay_client/src/websocket.rs @@ -3,7 +3,6 @@ use tokio::spawn; #[cfg(target_arch = "wasm32")] use wasm_bindgen_futures::spawn_local as spawn; use { - self::connection::connection_event_loop, crate::{ error::{ClientError, Error}, ConnectionOptions, @@ -30,11 +29,10 @@ use { tokio::sync::{ mpsc::{self, UnboundedReceiver, UnboundedSender}, oneshot, - Mutex, }, }; pub use { - connection::{Connection, ConnectionControl}, + connection::{connection_event_loop, Connection, ConnectionControl}, fetch::*, inbound::*, outbound::*, @@ -145,7 +143,6 @@ type SubscriptionResult = Result>; #[derive(Debug, Clone)] pub struct Client { control_tx: UnboundedSender, - control_rx: Option>>>, } impl Client { @@ -155,26 +152,22 @@ impl Client { T: ConnectionHandler, { let (control_tx, control_rx) = mpsc::unbounded_channel(); - spawn(connection_event_loop(control_rx, handler)); - Self { - control_tx, - control_rx: None, - } + Self { control_tx } } - /// Creates a new managed [`Client`] with the provided handler. - pub fn new_unmanaged() -> Self { + /// Creates a new [`Client`] with a custom callback function to handle the + /// control receiver and handler. + pub fn new_with_callback(handler: T, f: F) -> (Self, R) + where + T: ConnectionHandler, + F: Fn(UnboundedReceiver, T) -> R, + { let (control_tx, control_rx) = mpsc::unbounded_channel(); - Self { - control_tx, - control_rx: Some(Arc::new(control_rx.into())), - } - } + let res = f(control_rx, handler); - pub fn control_rx(&self) -> Option>>> { - self.control_rx.clone() + (Self { control_tx }, res) } /// Publishes a message over the network on given topic. diff --git a/relay_client/src/websocket/connection.rs b/relay_client/src/websocket/connection.rs index 20a4d82..22787ad 100644 --- a/relay_client/src/websocket/connection.rs +++ b/relay_client/src/websocket/connection.rs @@ -32,7 +32,7 @@ pub enum ConnectionControl { OutboundRequest(OutboundRequest), } -pub(super) async fn connection_event_loop( +pub async fn connection_event_loop( mut control_rx: UnboundedReceiver, mut handler: T, ) where @@ -81,6 +81,12 @@ pub(super) async fn connection_event_loop( } StreamEvent::InboundError(error) => { + if let ClientError::WebsocketClient(WebsocketClientError::Transport(err)) = &error { + let err_str = err.to_string(); + if err_str.contains("Operation timed out") || err_str.contains("unexpected end of file") { + conn.reset(); + }; + } handler.inbound_error(error); } @@ -123,7 +129,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 c3206d1..0d2ed4f 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -12,7 +12,13 @@ cacao = [ ] [dependencies] +alloy = { version = "0.3.6", optional = true, features = ["json-rpc", "provider-http", "contract", "rpc-types-eth"] } +anyhow = "1.0.86" bs58 = "0.4" +chrono = { version = "0.4", default-features = false, features = [ + "std", + "clock", +] } data-encoding = "2.3" derive_more = { version = "0.99", default-features = false, features = [ "display", @@ -20,25 +26,24 @@ derive_more = { version = "0.99", default-features = false, features = [ "as_ref", "as_mut", ] } -serde = { version = "1.0", features = ["derive", "rc"] } -serde-aux = { version = "4.1", default-features = false } -serde_json = "1.0" -thiserror = "1.0" ed25519-dalek = { version = "2.1.1", features = ["rand_core"] } -rand = "0.8" -chrono = { version = "0.4", default-features = false, features = [ - "std", - "clock", -] } -regex = "1.7" -once_cell = "1.16" jsonwebtoken = "8.1" k256 = { version = "0.13", optional = true } +once_cell = "1.16" +paste = "1.0.15" +rand = "0.8.5" +regex = "1.7" +serde = { version = "1.0", features = ["derive", "rc"] } +serde-aux = { version = "4.1", default-features = false } +serde_json = "1.0" sha3 = { version = "0.10", optional = true } sha2 = { version = "0.10.6" } -url = "2" -alloy = { version = "0.3.6", optional = true, features = ["json-rpc", "provider-http", "contract", "rpc-types-eth"] } strum = { version = "0.26", features = ["strum_macros", "derive"] } +thiserror = "1.0" +url = "2" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +getrandom = { version = "0.2", features = ["js"] } [dev-dependencies] tokio = { version = "1.35.1", features = ["test-util", "macros"] } diff --git a/relay_rpc/src/auth/cacao/signature/test_helpers.rs b/relay_rpc/src/auth/cacao/signature/test_helpers.rs index 4f54209..6fa3e31 100644 --- a/relay_rpc/src/auth/cacao/signature/test_helpers.rs +++ b/relay_rpc/src/auth/cacao/signature/test_helpers.rs @@ -56,6 +56,7 @@ pub async fn deploy_contract( &cache_folder, "--out", &out_folder, + "--broadcast", ]; if let Some(arg) = constructor_arg { args.push("--constructor-args"); diff --git a/relay_rpc/src/jwt.rs b/relay_rpc/src/jwt.rs index 120a99d..d37599f 100644 --- a/relay_rpc/src/jwt.rs +++ b/relay_rpc/src/jwt.rs @@ -67,7 +67,7 @@ impl Default for JwtHeader<'_> { } } -impl<'a> JwtHeader<'a> { +impl JwtHeader<'_> { pub fn is_valid(&self) -> bool { self.typ == JWT_HEADER_TYP && self.alg == JWT_HEADER_ALG } diff --git a/relay_rpc/src/rpc.rs b/relay_rpc/src/rpc.rs index 751eaec..bd36eb8 100644 --- a/relay_rpc/src/rpc.rs +++ b/relay_rpc/src/rpc.rs @@ -3,6 +3,18 @@ use { crate::domain::{DidKey, MessageId, SubscriptionId, Topic}, + params::{ + pairing_delete::PairingDeleteRequest, + pairing_extend::PairingExtendRequest, + pairing_ping::PairingPingRequest, + session_delete::SessionDeleteRequest, + session_event::SessionEventRequest, + session_extend::SessionExtendRequest, + session_propose::SessionProposeRequest, + session_request::SessionRequestRequest, + session_settle::SessionSettleRequest, + session_update::SessionUpdateRequest, + }, serde::{de::DeserializeOwned, Deserialize, Serialize}, std::{fmt::Debug, sync::Arc}, }; @@ -10,6 +22,7 @@ pub use {error::*, watch::*}; pub mod error; pub mod msg_id; +pub mod params; #[cfg(test)] mod tests; pub mod watch; @@ -808,6 +821,30 @@ pub enum Params { /// topic the data is published for. #[serde(rename = "irn_subscription", alias = "iridium_subscription")] Subscription(Subscription), + + #[serde(rename = "wc_pairingExtend")] + PairingExtend(PairingExtendRequest), + #[serde(rename = "wc_pairingDelete")] + PairingDelete(PairingDeleteRequest), + #[serde(rename = "wc_pairingPing")] + PairingPing(PairingPingRequest), + + #[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(()), } /// Data structure representing a JSON RPC request. @@ -858,6 +895,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/params.rs b/relay_rpc/src/rpc/params.rs new file mode 100644 index 0000000..a7fb84c --- /dev/null +++ b/relay_rpc/src/rpc/params.rs @@ -0,0 +1,332 @@ +use { + super::{ErrorData, Params}, + pairing_delete::PairingDeleteRequest, + pairing_extend::PairingExtendRequest, + pairing_ping::PairingPingRequest, + paste::paste, + serde::{Deserialize, Serialize}, + serde_json::Value, + session_delete::SessionDeleteRequest, + session_event::SessionEventRequest, + session_extend::SessionExtendRequest, + session_propose::{SessionProposeRequest, SessionProposeResponse}, + session_request::SessionRequestRequest, + session_settle::SessionSettleRequest, + session_update::SessionUpdateRequest, +}; + +pub mod arbitrary; +pub mod pairing_delete; +pub mod pairing_extend; +pub mod pairing_ping; +pub mod session; +pub mod session_delete; +pub mod session_event; +pub mod session_extend; +pub mod session_ping; +pub mod session_propose; +pub mod session_request; +pub mod session_settle; +pub mod session_update; + +/// Metadata associated with a pairing. +#[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, +} + +/// Information about the relay used for communication. +#[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, +} + +/// 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, +} + +/// 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; +} + +/// Errors covering API payload parameter conversion issues. +#[derive(Debug, thiserror::Error)] +pub enum ParamsError { + /// Pairing API serialization/deserialization issues. + #[error("Failure serializing/deserializing Sign API parameters: {0}")] + Serde(#[from] serde_json::Error), + /// Pairing API invalid response tag. + #[error("Response tag={0} does not match any of the Sign API methods")] + ResponseTag(u32), +} + +/// 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), +} + +// 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(_) => session_propose::[], + [<$param_type>]::SessionSettle(_) => session_settle::[], + [<$param_type>]::SessionRequest(_) => session_request::[], + [<$param_type>]::SessionUpdate(_) => session_update::[], + [<$param_type>]::SessionDelete(_) => session_delete::[], + [<$param_type>]::SessionEvent(_) => session_event::[], + [<$param_type>]::SessionExtend(_) => session_extend::[], + [<$param_type>]::SessionPing(_) => session_ping::[], + [<$param_type>]::PairingDelete(_) => pairing_delete::[], + [<$param_type>]::PairingExtend(_) => pairing_extend::[], + [<$param_type>]::PairingPing(_) => pairing_ping::[], + [<$param_type>]::Arbitrary(_) => arbitrary::[], + } + } + } + } + } +} + +// 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 == session_propose::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionPropose(serde_json::from_value(value)?)) + } + tag if tag == session_settle::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionSettle(serde_json::from_value(value)?)) + } + tag if tag == session_request::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionRequest(serde_json::from_value(value)?)) + } + tag if tag == session_delete::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionDelete(serde_json::from_value(value)?)) + } + tag if tag == session_extend::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionExtend(serde_json::from_value(value)?)) + } + tag if tag == session_update::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionUpdate(serde_json::from_value(value)?)) + } + tag if tag == session_event::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionEvent(serde_json::from_value(value)?)) + } + tag if tag == session_event::IRN_RESPONSE_METADATA.tag => { + Ok(Self::SessionPing(serde_json::from_value(value)?)) + } + tag if tag == pairing_delete::IRN_RESPONSE_METADATA.tag => { + Ok(Self::PairingDelete(serde_json::from_value(value)?)) + } + tag if tag == pairing_extend::IRN_RESPONSE_METADATA.tag => { + Ok(Self::PairingExtend(serde_json::from_value(value)?)) + } + tag if tag == pairing_ping::IRN_RESPONSE_METADATA.tag => { + Ok(Self::PairingPing(serde_json::from_value(value)?)) + } + tag if tag == arbitrary::IRN_RESPONSE_METADATA.tag => { + Ok(Self::Arbitrary(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 { + SessionPropose(SessionProposeRequest), + SessionSettle(SessionSettleRequest), + SessionUpdate(SessionUpdateRequest), + SessionExtend(SessionExtendRequest), + SessionRequest(SessionRequestRequest), + SessionEvent(SessionEventRequest), + SessionDelete(SessionDeleteRequest), + SessionPing(()), + PairingExtend(PairingExtendRequest), + PairingDelete(PairingDeleteRequest), + PairingPing(PairingPingRequest), + Arbitrary(Value), +} + +impl_relay_protocol_metadata!(RequestParams, request); + +impl From for Params { + fn from(value: RequestParams) -> Self { + match value { + RequestParams::PairingPing(param) => Params::PairingPing(param), + RequestParams::PairingDelete(param) => Params::PairingDelete(param), + RequestParams::SessionPing(()) => Params::SessionPing(()), + RequestParams::SessionPropose(param) => Params::SessionPropose(param), + RequestParams::SessionSettle(param) => Params::SessionSettle(param), + RequestParams::SessionUpdate(param) => Params::SessionUpdate(param), + RequestParams::SessionExtend(param) => Params::SessionExtend(param), + RequestParams::SessionRequest(param) => Params::SessionRequest(param), + RequestParams::SessionEvent(param) => Params::SessionEvent(param), + RequestParams::SessionDelete(param) => Params::SessionDelete(param), + RequestParams::PairingExtend(param) => Params::PairingExtend(param), + RequestParams::Arbitrary(_param) => unreachable!(), + } + } +} + +/// 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), + + PairingExtend(bool), + PairingDelete(bool), + PairingPing(bool), + Arbitrary(Value), +} + +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), + PairingDelete(ErrorData), + PairingExtend(ErrorData), + PairingPing(ErrorData), + Arbitrary(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)?)) + } +} + +impl ResponseParamsError { + pub fn error(&self) -> ErrorData { + match self { + Self::SessionPing(data) + | Self::PairingPing(data) + | Self::SessionEvent(data) + | Self::SessionExtend(data) + | Self::SessionSettle(data) + | Self::SessionUpdate(data) + | Self::SessionDelete(data) + | Self::SessionPropose(data) + | Self::SessionRequest(data) + | Self::PairingDelete(data) + | Self::PairingExtend(data) + | Self::Arbitrary(data) => data.clone(), + } + } + + pub fn irn_metadata(&self) -> IrnMetadata { + match self { + Self::SessionPing(_data) + | Self::PairingPing(_data) + | Self::SessionEvent(_data) + | Self::SessionExtend(_data) + | Self::SessionSettle(_data) + | Self::SessionUpdate(_data) + | Self::SessionDelete(_data) + | Self::SessionPropose(_data) + | Self::SessionRequest(_data) + | Self::PairingDelete(_data) + | Self::PairingExtend(_data) + | Self::Arbitrary(_data) => self.irn_metadata(), + } + } +} diff --git a/relay_rpc/src/rpc/params/arbitrary.rs b/relay_rpc/src/rpc/params/arbitrary.rs new file mode 100644 index 0000000..36fe040 --- /dev/null +++ b/relay_rpc/src/rpc/params/arbitrary.rs @@ -0,0 +1,13 @@ +use super::IrnMetadata; + +pub(crate) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1109, + ttl: 60, + prompt: false, +}; + +pub(crate) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1108, + ttl: 300, + prompt: true, +}; diff --git a/relay_rpc/src/rpc/params/pairing_delete.rs b/relay_rpc/src/rpc/params/pairing_delete.rs new file mode 100644 index 0000000..a7ca817 --- /dev/null +++ b/relay_rpc/src/rpc/params/pairing_delete.rs @@ -0,0 +1,26 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/core/pairing/rpc-methods +//! #wc_pairingdelete + +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; + +pub(crate) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1000, + ttl: 86400, + prompt: false, +}; + +pub(crate) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1001, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PairingDeleteRequest { + pub code: i64, + pub message: String, +} diff --git a/relay_rpc/src/rpc/params/pairing_extend.rs b/relay_rpc/src/rpc/params/pairing_extend.rs new file mode 100644 index 0000000..c2aba9a --- /dev/null +++ b/relay_rpc/src/rpc/params/pairing_extend.rs @@ -0,0 +1,25 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/core/pairing/rpc-methods +//! #wc_pairingextend + +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; + +pub(crate) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1004, + ttl: 86400, + prompt: false, +}; + +pub(crate) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1005, + ttl: 86400, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PairingExtendRequest { + pub expiry: u64, +} diff --git a/relay_rpc/src/rpc/params/pairing_ping.rs b/relay_rpc/src/rpc/params/pairing_ping.rs new file mode 100644 index 0000000..27908f8 --- /dev/null +++ b/relay_rpc/src/rpc/params/pairing_ping.rs @@ -0,0 +1,23 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/core/pairing/rpc-methods +//! #wc_pairingping + +use { + super::IrnMetadata, + serde::{Deserialize, Serialize}, +}; + +pub(crate) const IRN_REQUEST_METADATA: IrnMetadata = IrnMetadata { + tag: 1002, + ttl: 30, + prompt: false, +}; + +pub(crate) const IRN_RESPONSE_METADATA: IrnMetadata = IrnMetadata { + tag: 1003, + ttl: 30, + prompt: false, +}; + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PairingPingRequest {} diff --git a/relay_rpc/src/rpc/params/session.rs b/relay_rpc/src/rpc/params/session.rs new file mode 100644 index 0000000..355d1a4 --- /dev/null +++ b/relay_rpc/src/rpc/params/session.rs @@ -0,0 +1,619 @@ +use { + regex::Regex, + serde::{Deserialize, Serialize}, + std::{ + collections::{BTreeMap, BTreeSet}, + ops::Deref, + sync::OnceLock, + }, +}; + +/// 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 NamespaceError { + #[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<(), NamespaceError> { + let join_err = |required: &BTreeSet, ours: &BTreeSet| -> String { + required + .difference(ours) + .map(|s| s.as_str()) + .collect::>() + .join(",") + }; + + // validate chains + if !self.chains.is_superset(&required.chains) { + return Err(NamespaceError::UnsupportedChains(join_err( + &required.chains, + &self.chains, + ))); + } + + // validate methods + if !self.methods.is_superset(&required.methods) { + return Err(NamespaceError::UnsupportedMethods(join_err( + &required.methods, + &self.methods, + ))); + } + + // validate events + if !self.events.is_superset(&required.events) { + return Err(NamespaceError::UnsupportedEvents(join_err( + &required.events, + &self.events, + ))); + } + + Ok(()) + } + + pub fn chains_caip2_validate( + &self, + namespace: &str, + reference: Option<&str>, + ) -> Result<(), NamespaceError> { + // 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(NamespaceError::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(|| NamespaceError::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(NamespaceError::UnsupportedChainsNamespace( + namespace.to_string(), + chain_namespace.to_string(), + )); + } + + let chain_reference = captures + .name("reference") + .map(|m| m.as_str()) + .ok_or_else(|| NamespaceError::UnsupportedChainsCaip2(namespace.to_string()))?; + + if let Some(r) = reference { + if r != chain_reference { + return Err(NamespaceError::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<(), NamespaceError> { + if self.is_empty() { + return Err(NamespaceError::UnsupportedNamespace( + "None supported".to_string(), + )); + } + + for (name, other) in required.iter() { + let ours = self + .get(name) + .ok_or_else(|| NamespaceError::UnsupportedNamespace(name.to_string()))?; + ours.supported(other)?; + } + + Ok(()) + } + + pub fn caip2_validate(&self) -> Result<(), NamespaceError> { + let caip_regex = get_caip2_regex(); + for (name, namespace) in self.deref() { + let captures = caip_regex + .captures(name) + .ok_or_else(|| NamespaceError::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(()) + } +} + +/// TODO: some validation from `ProposeNamespaces` should be re-used. +/// TODO: caip-10 validation. +#[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 + } +} + +impl SettleNamespaces { + pub fn caip2_validate(&self) -> Result<(), NamespaceError> { + let caip_regex = get_caip2_regex(); + for (name, namespace) in self.deref() { + let captures = caip_regex + .captures(name) + .ok_or_else(|| NamespaceError::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(()) + } +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Namespace { + pub chains: Option>, + pub accounts: Option>, + pub methods: BTreeSet, + pub events: BTreeSet, +} + +impl Namespace { + pub fn chains_caip2_validate( + &self, + namespace: &str, + reference: Option<&str>, + ) -> Result<(), NamespaceError> { + // https://specs.walletconnect.com/2.0/specs/clients/sign/ + // namespaces#13-chains-might-be-omitted-if-the-caip-2-is-defined-in-the-index + let chains = self.chains.clone().unwrap_or_default(); + match (reference, chains.is_empty()) { + (None, true) => return Err(NamespaceError::UnsupportedChainsEmpty), + (Some(_), true) => return Ok(()), + _ => {} + } + + let caip_regex = get_caip2_regex(); + for chain in chains.iter() { + let captures = caip_regex + .captures(chain) + .ok_or_else(|| NamespaceError::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(NamespaceError::UnsupportedChainsNamespace( + namespace.to_string(), + chain_namespace.to_string(), + )); + } + + let chain_reference = captures + .name("reference") + .map(|m| m.as_str()) + .ok_or_else(|| NamespaceError::UnsupportedChainsCaip2(namespace.to_string()))?; + + if let Some(r) = reference { + if r != chain_reference { + return Err(NamespaceError::UnsupportedChainsCaip2( + namespace.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. +#[cfg(test)] +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. +#[cfg(test)] +use serde::de::DeserializeOwned; + +#[cfg(test)] +pub(crate) fn param_serde_test(json: &str) -> anyhow::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(()) +} + +#[cfg(test)] +mod tests { + use {super::*, anyhow::Result}; + + // ======================================================================================================== + // 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(NamespaceError::UnsupportedChains("1".to_string())), + ); + + ours.chains.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(NamespaceError::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(NamespaceError::UnsupportedMethods("1".to_string())), + ); + + ours.methods.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(NamespaceError::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(NamespaceError::UnsupportedEvents("1".to_string())), + ); + + ours.events.remove("2"); + assert_eq!( + ours.supported(&theirs), + Err(NamespaceError::UnsupportedEvents("1,2".to_string())), + ); + } + + // ======================================================================================================== + // CAIP-2 TESTS: https://chainagnostic.org/CAIPs/caip-2 + // ======================================================================================================== + #[test] + fn caip2_test_cases() -> Result<(), NamespaceError> { + 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(|| NamespaceError::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(NamespaceError::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<(), NamespaceError> { + 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<(), NamespaceError> { + 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(NamespaceError::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<(), NamespaceError> { + 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<(), NamespaceError> { + 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(NamespaceError::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<(), NamespaceError> { + 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(NamespaceError::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(NamespaceError::UnsupportedNamespaceKey("**".to_string())), + ); + + 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..2e0fde1 --- /dev/null +++ b/relay_rpc/src/rpc/params/session_delete.rs @@ -0,0 +1,43 @@ +//! 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::*, crate::rpc::params::session::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..03ec7b2 --- /dev/null +++ b/relay_rpc/src/rpc/params/session_event.rs @@ -0,0 +1,58 @@ +//! 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 { + pub name: String, + /// Opaque blockchain RPC data. + /// + /// Parsing is deferred to a higher level, blockchain RPC aware code. + pub data: serde_json::Value, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Hash, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionEventRequest { + pub event: Event, + pub chain_id: String, +} + +#[cfg(test)] +mod tests { + use {super::*, crate::rpc::params::session::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..f5c8601 --- /dev/null +++ b/relay_rpc/src/rpc/params/session_extend.rs @@ -0,0 +1,37 @@ +//! 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::*, crate::rpc::params::session::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..fae8dce --- /dev/null +++ b/relay_rpc/src/rpc/params/session_ping.rs @@ -0,0 +1,34 @@ +//! 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::*, crate::rpc::params::session::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..06dda74 --- /dev/null +++ b/relay_rpc/src/rpc/params/session_propose.rs @@ -0,0 +1,91 @@ +use { + super::{session::ProposeNamespaces, IrnMetadata, 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub optional_namespaces: Option, +} + +#[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::*, crate::rpc::params::session::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..cef29d0 --- /dev/null +++ b/relay_rpc/src/rpc/params/session_request.rs @@ -0,0 +1,70 @@ +//! 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::*, crate::rpc::params::session::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..fc9014a --- /dev/null +++ b/relay_rpc/src/rpc/params/session_settle.rs @@ -0,0 +1,92 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionsettle + +use { + super::{session::SettleNamespaces, IrnMetadata}, + crate::rpc::params::{Metadata, Relay}, + serde::{Deserialize, Serialize}, + serde_json::Value, +}; + +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, + pub session_properties: Option, +} + +#[cfg(test)] +mod tests { + use {super::*, crate::rpc::params::session::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": { + "chains": [], + "accounts": [ + "eip155:5:0xBA5BA3955463ADcc7aa3E33bbdfb8A68e0933dD8" + ], + "methods": [ + "eth_sendTransaction", + "eth_sign", + "eth_signTransaction", + "eth_signTypedData", + "personal_sign" + ], + "events": [ + "accountsChanged", + "chainChanged" + ] + } + }, + "expiry": 1675734962, + "sessionProperties": null + } + "#; + + 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..13f170a --- /dev/null +++ b/relay_rpc/src/rpc/params/session_update.rs @@ -0,0 +1,62 @@ +//! https://specs.walletconnect.com/2.0/specs/clients/sign/rpc-methods +//! #wc_sessionupdate + +use { + super::{session::SettleNamespaces, IrnMetadata}, + 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::*, crate::rpc::params::session::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": { + "chains": [], + "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/src/lib.rs b/src/lib.rs index 03b8a93..9516386 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "pairing_api")] +pub use pairing_api as pairing; #[cfg(feature = "client")] pub use relay_client as client; #[cfg(feature = "rpc")] diff --git a/wc_common/Cargo.toml b/wc_common/Cargo.toml new file mode 100644 index 0000000..0eaa855 --- /dev/null +++ b/wc_common/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "wc_common" +version = "0.1.0" +edition = "2021" + +[dependencies] +base64 = "0.21.2" +chacha20poly1305 = "0.10" +thiserror = "1.0" diff --git a/wc_common/src/crypto.rs b/wc_common/src/crypto.rs new file mode 100644 index 0000000..2b66d7f --- /dev/null +++ b/wc_common/src/crypto.rs @@ -0,0 +1,247 @@ +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), + #[error("Empty data")] + EmptyData, + #[error("Empty data, start:{0} - end:{1}")] + InvalidIndices(usize, usize), +} + +#[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 { + if data.is_empty() { + return Err(PayloadError::InitVecLen(0)); + } + + 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; + if init_vec_end_index < init_vec_start_index { + return Err(PayloadError::InvalidIndices( + init_vec_start_index, + init_vec_end_index, + )); + } + if data.len() < sealed_start_index { + return Err(PayloadError::InitVecLen(data.len())); + } + + 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; + if init_vec_end_index < init_vec_start_index { + return Err(PayloadError::InvalidIndices( + init_vec_start_index, + 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) + })?; + if key_end_index < sealed_start_index { + return Err(PayloadError::InvalidIndices( + sealed_start_index, + key_end_index, + )); + } + if data.len() < sealed_start_index { + return Err(PayloadError::InitVecLen(data.len())); + } + + 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)), + } + } +} + +#[inline] +fn validate_symkey_len(len: usize) -> bool { + len == SYM_KEY_LENGTH +} +/// Encrypts and encodes the plain-text payload. +/// +/// TODO: RNG as an input +pub fn encrypt_and_encode( + envelope_type: EnvelopeType, + msg: T, + key: &[u8], +) -> Result +where + T: AsRef<[u8]>, +{ + // validate sym_key len. + if !validate_symkey_len(key.len()) { + return Err(PayloadError::SymKeyLen(key.len())); + } + + 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: &[u8]) -> Result +where + T: AsRef<[u8]>, +{ + // validate sym_key len. + if key.len() != 32 { + return Err(PayloadError::SymKeyLen(key.len())); + } + + 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: &[u8]) -> Result, PayloadError> { + // validate sym_key len. + if key.len() != 32 { + return Err(PayloadError::SymKeyLen(key.len())); + } + + 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: &[u8]) -> Result, PayloadError> { + // validate sym_key len. + if key.len() != 32 { + return Err(PayloadError::SymKeyLen(key.len())); + } + + 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/wc_common/src/lib.rs b/wc_common/src/lib.rs new file mode 100644 index 0000000..07aa0bf --- /dev/null +++ b/wc_common/src/lib.rs @@ -0,0 +1,2 @@ +mod crypto; +pub use crypto::*;