diff --git a/Cargo.toml b/Cargo.toml index 8b29593..a70043c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,18 +9,21 @@ license = "Apache-2.0" [workspace] members = [ "relay_client", - "relay_rpc" + "relay_rpc", + "sign_api" ] [features] default = ["full"] -full = ["client", "rpc"] +full = ["client", "rpc", "sign_api"] client = ["dep:relay_client"] rpc = ["dep:relay_rpc"] +sign_api = ["dep:sign_api"] [dependencies] relay_client = { path = "./relay_client", optional = true } relay_rpc = { path = "./relay_rpc", optional = true } +sign_api = { path = "./sign_api", optional = true } [dev-dependencies] anyhow = "1" diff --git a/sign_api/Cargo.toml b/sign_api/Cargo.toml new file mode 100644 index 0000000..95b9004 --- /dev/null +++ b/sign_api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "sign_api" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = "1" +base58 = "0.2" +base64 = "0.21" +base64-url = "2.0" +chacha20poly1305 = "0.10" +chrono = "0.4" +hex = "0.4" +hex-literal = "0.4" +hkdf = "0.12" +lazy_static = "1.4" +once_cell = "1.16" +paste = "1.0" +rand = "0.8" +regex = "1.10" +sha2 = "0.10" +serde = { version = "1.0", features = ["derive", "rc"] } +serde_json = "1.0" +thiserror = "1.0" +x25519-dalek = { version = "2.0", features = ["static_secrets"] } +url = "2.4" diff --git a/sign_api/src/crypto.rs b/sign_api/src/crypto.rs new file mode 100644 index 0000000..45b480b --- /dev/null +++ b/sign_api/src/crypto.rs @@ -0,0 +1,2 @@ +pub mod payload; +pub mod session; diff --git a/sign_api/src/crypto/payload.rs b/sign_api/src/crypto/payload.rs new file mode 100644 index 0000000..2f8c58f --- /dev/null +++ b/sign_api/src/crypto/payload.rs @@ -0,0 +1,221 @@ +use anyhow::Result; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; +use chacha20poly1305::aead::{Aead, KeyInit, OsRng, Payload}; +use chacha20poly1305::{AeadCore, ChaCha20Poly1305, Nonce}; + +const TYPE_0: u8 = 0; +const TYPE_1: u8 = 1; +const TYPE_INDEX: usize = 0; +const TYPE_LENGTH: usize = 1; +const IV_LENGTH: usize = 12; +const PUB_KEY_LENGTH: usize = 32; +const SYM_KEY_LENGTH: usize = 32; + +pub type Iv = [u8; IV_LENGTH]; +pub type SymKey = [u8; SYM_KEY_LENGTH]; +pub type PubKey = [u8; PUB_KEY_LENGTH]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EnvelopeType<'a> { + Type0, + Type1 { sender_public_key: &'a PubKey }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct EncodingParams<'a> { + sealed: &'a [u8], + iv: &'a Iv, + envelope_type: EnvelopeType<'a>, +} + +impl<'a> EncodingParams<'a> { + fn parse_decoded(data: &'a [u8]) -> Result { + let envelope_type = data[0]; + match envelope_type { + TYPE_0 => { + let iv_index: usize = TYPE_INDEX + TYPE_LENGTH; + let sealed_index: usize = iv_index + IV_LENGTH; + Ok(EncodingParams { + iv: data[iv_index..=IV_LENGTH].try_into()?, + sealed: &data[sealed_index..], + envelope_type: EnvelopeType::Type0, + }) + } + TYPE_1 => { + let key_index: usize = TYPE_INDEX + TYPE_LENGTH; + let iv_index: usize = key_index + PUB_KEY_LENGTH; + let sealed_index: usize = iv_index + IV_LENGTH; + Ok(EncodingParams { + iv: data[iv_index..=IV_LENGTH].try_into()?, + sealed: &data[sealed_index..], + envelope_type: EnvelopeType::Type1 { + sender_public_key: data[key_index..=PUB_KEY_LENGTH].try_into()?, + }, + }) + } + _ => anyhow::bail!("Invalid envelope type: {}", envelope_type), + } + } +} + +// TODO: RNG as an input +pub fn encrypt_and_encode(envelope_type: EnvelopeType, msg: &str, key: &SymKey) -> Result { + let payload = Payload { + msg: msg.as_bytes(), + 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()?, + )) +} + +pub fn decode_and_decrypt_type0(msg: &str, key: &SymKey) -> Result { + let data = BASE64_STANDARD.decode(msg)?; + let decoded = EncodingParams::parse_decoded(&data)?; + if let EnvelopeType::Type1 { .. } = decoded.envelope_type { + anyhow::bail!("Expected envelope type 0"); + } + + let payload = Payload { + msg: decoded.sealed, + aad: &[], + }; + let decrypted = decrypt(decoded.iv.try_into()?, payload, key)?; + + Ok(String::from_utf8(decrypted)?) +} + +fn encrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result> { + let cipher = ChaCha20Poly1305::new(key.try_into()?); + let sealed = cipher + .encrypt(nonce, payload) + .map_err(|e| anyhow::anyhow!("Encryption failed, err: {:?}", e))?; + + Ok(sealed) +} + +fn encode(envelope_type: EnvelopeType, sealed: &[u8], iv: &Iv) -> String { + match envelope_type { + EnvelopeType::Type0 => BASE64_STANDARD.encode([&[TYPE_0], iv.as_slice(), sealed].concat()), + EnvelopeType::Type1 { sender_public_key } => { + BASE64_STANDARD.encode([&[TYPE_1], sender_public_key.as_slice(), iv, sealed].concat()) + } + } +} + +fn decrypt(nonce: &Nonce, payload: Payload<'_, '_>, key: &SymKey) -> Result> { + let cipher = ChaCha20Poly1305::new(key.try_into()?); + let unsealed = cipher + .decrypt(nonce, payload) + .map_err(|e| anyhow::anyhow!("Decryption failed, err: {:?}", e))?; + + Ok(unsealed) +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + // https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2 + // Below constans are taken from this section of the RFC. + + const PLAINTEXT: &str = r#"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."#; + const CIPHERTEXT: [u8; 114] = hex!( + "d3 1a 8d 34 64 8e 60 db 7b 86 af bc 53 ef 7e c2 + a4 ad ed 51 29 6e 08 fe a9 e2 b5 a7 36 ee 62 d6 + 3d be a4 5e 8c a9 67 12 82 fa fb 69 da 92 72 8b + 1a 71 de 0a 9e 06 0b 29 05 d6 a5 b6 7e cd 3b 36 + 92 dd bd 7f 2d 77 8b 8c 98 03 ae e3 28 09 1b 58 + fa b3 24 e4 fa d6 75 94 55 85 80 8b 48 31 d7 bc + 3f f4 de f0 8e 4b 7a 9d e5 76 d2 65 86 ce c6 4b + 61 16" + ); + const TAG: [u8; 16] = hex!("1a e1 0b 59 4f 09 e2 6a 7e 90 2e cb d0 60 06 91"); + const SYMKEY: SymKey = hex!( + "80 81 82 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f + 90 91 92 93 94 95 96 97 98 99 9a 9b 9c 9d 9e 9f" + ); + const AAD: [u8; 12] = hex!("50 51 52 53 c0 c1 c2 c3 c4 c5 c6 c7"); + const IV: Iv = hex!("07 00 00 00 40 41 42 43 44 45 46 47"); + + /// Tests WCv2 encoding and decoding. + #[test] + fn test_decode_encoded() -> Result<()> { + let iv: &Iv = IV.as_slice().try_into()?; + let sealed = [CIPHERTEXT.as_slice(), TAG.as_slice()].concat(); + + let encoded = encode(EnvelopeType::Type0, &sealed, iv); + assert_eq!( + encoded, + "AAcAAABAQUJDREVGR9MajTRkjmDbe4avvFPvfsKkre1RKW4I/qnitac27mLWPb6kXoypZxKC+vtp2pJyixpx3gqeBgspBdaltn7NOzaS3b1/LXeLjJgDruMoCRtY+rMk5PrWdZRVhYCLSDHXvD/03vCOS3qd5XbSZYbOxkthFhrhC1lPCeJqfpAuy9BgBpE=" + ); + + let data = BASE64_STANDARD.decode(&encoded)?; + let decoded = EncodingParams::parse_decoded(&data)?; + assert_eq!(decoded.envelope_type, EnvelopeType::Type0); + assert_eq!(decoded.sealed, sealed); + assert_eq!(decoded.iv, iv); + + Ok(()) + } + + /// Tests ChaCha20-Poly1305 encryption against the RFC test vector. + /// + /// https://www.rfc-editor.org/rfc/rfc7539#section-2.8.2 + /// Please note that this test vector has an + /// "Additional Authentication Data", in practice, we will likely + /// be using this algorithm without "AAD". + #[test] + fn test_encryption() -> Result<()> { + let payload = Payload { + msg: PLAINTEXT.as_bytes(), + aad: AAD.as_slice(), + }; + let iv = IV.as_slice().try_into()?; + + let sealed = encrypt(iv, payload, &SYMKEY)?; + assert_eq!(sealed, [CIPHERTEXT.as_slice(), TAG.as_slice()].concat()); + + Ok(()) + } + + /// Tests that encrypted message can be decrypted back. + #[test] + fn test_decrypt_encrypted() -> Result<()> { + let iv = IV.as_slice().try_into()?; + + let seal_payload = Payload { + msg: PLAINTEXT.as_bytes(), + aad: AAD.as_slice(), + }; + let sealed = encrypt(iv, seal_payload, &SYMKEY)?; + + let unseal_payload = Payload { + msg: &sealed, + aad: AAD.as_slice(), + }; + let unsealed = decrypt(iv, unseal_payload, &SYMKEY)?; + + assert_eq!(PLAINTEXT.to_string(), String::from_utf8(unsealed)?); + + Ok(()) + } + + /// Tests that plain text can be WCv2 serialized and deserialized back. + #[test] + fn test_encrypt_encode_decode_decrypt() -> Result<()> { + let encoded = encrypt_and_encode(EnvelopeType::Type0, PLAINTEXT, &SYMKEY)?; + let decoded = decode_and_decrypt_type0(&encoded, &SYMKEY)?; + assert_eq!(decoded, PLAINTEXT); + + Ok(()) + } +} diff --git a/sign_api/src/crypto/session.rs b/sign_api/src/crypto/session.rs new file mode 100644 index 0000000..ddc7ecc --- /dev/null +++ b/sign_api/src/crypto/session.rs @@ -0,0 +1,55 @@ +use anyhow::Result; +use hkdf::Hkdf; +use rand::rngs::OsRng; +use sha2::{Digest, Sha256}; +use x25519_dalek::{PublicKey, StaticSecret}; + +pub type SymKey = [u8; 32]; +pub type DhKey = [u8; 32]; + +#[allow(missing_debug_implementations)] + +pub struct SessionKey { + dh_private_key: StaticSecret, + dh_public_key: PublicKey, +} + +impl SessionKey { + pub fn new() -> Result { + let dh_private_key = StaticSecret::random_from_rng(OsRng); + let dh_public_key = PublicKey::from(&dh_private_key); + + Ok(Self { + dh_private_key, + dh_public_key, + }) + } + + pub fn generate_sym_key(&self, sender_public_key: DhKey) -> Result { + let dh_public_key = PublicKey::from(sender_public_key); + let ikm = self + .dh_private_key + .diffie_hellman(&dh_public_key) + .to_bytes(); + let hk = Hkdf::::new(None, &ikm); + let mut okm: SymKey = [0u8; 32]; + hk.expand(&[], &mut okm) + .map_err(|e| anyhow::anyhow!("Failed to generate SymKey: {e}"))?; + + Ok(okm) + } + + pub fn generate_topic(&self, sym_key: &SymKey) -> String { + let mut hasher = Sha256::new(); + hasher.update(sym_key); + hex::encode(hasher.finalize()) + } + + pub fn get_private_key(&self) -> Result { + Ok(self.dh_private_key.to_bytes()) + } + + pub fn get_public_key(&self) -> Result { + Ok(self.dh_public_key.to_bytes()) + } +} diff --git a/sign_api/src/lib.rs b/sign_api/src/lib.rs new file mode 100644 index 0000000..df91bc0 --- /dev/null +++ b/sign_api/src/lib.rs @@ -0,0 +1,3 @@ +pub mod crypto; +pub mod pairing_uri; +pub mod rpc; diff --git a/sign_api/src/pairing_uri.rs b/sign_api/src/pairing_uri.rs new file mode 100644 index 0000000..fd26128 --- /dev/null +++ b/sign_api/src/pairing_uri.rs @@ -0,0 +1,158 @@ +use std::fmt::{Debug, Formatter}; +use std::str::FromStr; + +use anyhow::Result; +use lazy_static::lazy_static; +use regex::Regex; +use url::Url; + +lazy_static! { + static ref TOPIC_AND_VERSION_RE: Regex = + Regex::new(r"^(?P[[[:word:]]-]+)@(?P\d+)$") + .expect("invalid TOPIC_AND_VERSION_RE in wallet_connect"); +} + +#[derive(Debug, Clone, thiserror::Error, PartialEq)] +pub enum ParseError { + #[error("Expecting protocol \"wc\" but \"{protocol}\" is found.")] + UnexpectedProtocol { protocol: String }, + #[error(transparent)] + Url(#[from] url::ParseError), + #[error("Failed to parse topic and version")] + InvalidTopicAndVersion, + #[error("Topic not found")] + TopicNotFound, + #[error("Version not found")] + VersionNotFound, + #[error("Relay protocol not found")] + RelayProtocolNotFound, + #[error("Key not found")] + KeyNotFound, + #[error("Failed to parse key: {0:?}")] + InvalidKey(#[from] hex::FromHexError), + #[error("Unexpected parameter, key: {0:?}, value: {1:?}")] + UnexpectedParameter(String, String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Params { + pub relay_protocol: String, + pub sym_key: Vec, + pub relay_data: Option, +} + +/// https://github.com/ethereum/EIPs/blob/master/EIPS/eip-1328.md +#[derive(Clone, Eq, PartialEq)] +pub struct Pairing { + pub topic: String, + pub version: String, + pub params: Params, +} + +impl Pairing { + fn parse_topic_and_version(path: &str) -> Result<(String, String), ParseError> { + let caps = TOPIC_AND_VERSION_RE + .captures(path) + .ok_or(ParseError::InvalidTopicAndVersion)?; + let topic = caps + .name("topic") + .ok_or(ParseError::TopicNotFound)? + .as_str() + .to_owned(); + let version = caps + .name("version") + .ok_or(ParseError::VersionNotFound)? + .as_str() + .to_owned(); + Ok((topic, version)) + } + + fn parse_params(url: &Url) -> Result { + let queries = url.query_pairs(); + + let mut relay_protocol: Option = None; + let mut sym_key: Option = None; + let mut relay_data: Option = None; + for (k, v) in queries { + match k.as_ref() { + "relay-protocol" => relay_protocol = Some((*v).to_owned()), + "symKey" => sym_key = Some((*v).to_owned()), + "relay-data" => relay_data = Some((*v).to_owned()), + _ => { + return Result::Err(ParseError::UnexpectedParameter( + (*k).to_owned(), + (*v).to_owned(), + )) + } + } + } + + Ok(Params { + relay_protocol: relay_protocol.ok_or(ParseError::RelayProtocolNotFound)?, + sym_key: hex::decode(sym_key.ok_or(ParseError::KeyNotFound)?)?, + relay_data, + }) + } +} + +impl Debug for Pairing { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WalletConnectUrl") + .field("topic", &self.topic) + .field("version", &self.version) + .field("relay-protocol", &self.params.relay_protocol) + .field("key", &"***") + .field( + "relay-data", + &self.params.relay_data.as_deref().unwrap_or(""), + ) + .finish() + } +} + +impl FromStr for Pairing { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + let url = Url::from_str(s)?; + + if url.scheme() != "wc" { + return Result::Err(ParseError::UnexpectedProtocol { + protocol: url.scheme().to_owned(), + }); + } + + let (topic, version) = Self::parse_topic_and_version(url.path())?; + Ok(Self { + topic, + version, + params: Self::parse_params(&url)?, + }) + } +} + +#[cfg(test)] +mod tests { + use hex_literal::hex; + + use super::*; + + #[test] + fn parse_uri() { + let uri = "wc:c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168@2?relay-protocol=waku&symKey=7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b"; + + let actual = Pairing { + topic: "c9e6d30fb34afe70a15c14e9337ba8e4d5a35dd695c39b94884b0ee60c69d168".to_owned(), + version: "2".to_owned(), + params: Params { + relay_protocol: "waku".to_owned(), + sym_key: hex!("7ff3e362f825ab868e20e767fe580d0311181632707e7c878cbeca0238d45b8b") + .into(), + relay_data: None, + }, + }; + let expected = Pairing::from_str(uri).unwrap(); + + assert_eq!(actual, expected); + } +} diff --git a/sign_api/src/rpc.rs b/sign_api/src/rpc.rs new file mode 100644 index 0000000..a32cd17 --- /dev/null +++ b/sign_api/src/rpc.rs @@ -0,0 +1,197 @@ +//! The crate exports common types used when interacting with messages between +//! clients. This also includes communication over HTTP between relays. + +use { + serde::{Deserialize, Serialize}, + std::{fmt::Debug, sync::Arc}, +}; + +mod params; + +use anyhow::Result; +use chrono::Utc; +pub use params::*; + +/// Version of the WalletConnect protocol that we're implementing. +pub const JSON_RPC_VERSION_STR: &str = "2.0"; + +pub static JSON_RPC_VERSION: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| Arc::from(JSON_RPC_VERSION_STR)); + +/// Errors covering payload validation problems. +#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)] +pub enum ValidationError { + #[error("Invalid request ID")] + RequestId, + + #[error("Invalid JSON RPC version")] + JsonRpcVersion, +} + +/// Enum representing a JSON RPC payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Payload { + Request(Request), + Response(Response), +} + +impl From for Payload { + fn from(value: Request) -> Self { + Payload::Request(value) + } +} + +impl From for Payload { + fn from(value: Response) -> Self { + Payload::Response(value) + } +} + +impl Payload { + /// Returns the message ID contained within the payload. + pub fn id(&self) -> u64 { + match self { + Self::Request(req) => req.id, + Self::Response(res) => res.id, + } + } + + pub fn validate(&self) -> Result<(), ValidationError> { + match self { + Self::Request(request) => request.validate(), + Self::Response(response) => response.validate(), + } + } + + pub fn irn_tag_in_range(tag: u32) -> bool { + (1100..=1115).contains(&tag) + } +} + +/// Data structure representing a JSON RPC request. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Request { + /// ID this message corresponds to. + pub id: u64, + + /// The JSON RPC version. + pub jsonrpc: Arc, + + /// The parameters required to fulfill this request. + #[serde(flatten)] + pub params: RequestParam, +} + +impl Request { + /// Create a new instance. + pub fn new(params: RequestParam) -> Self { + Self { + id: Utc::now().timestamp_micros() as u64, + jsonrpc: JSON_RPC_VERSION_STR.into(), + params, + } + } + + /// Validates the request payload. + pub fn validate(&self) -> Result<(), ValidationError> { + if self.jsonrpc.as_ref() != JSON_RPC_VERSION_STR { + return Err(ValidationError::JsonRpcVersion); + } + + Ok(()) + } +} + +/// Data structure representing a successful JSON RPC response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Response { + /// ID this message corresponds to. + pub id: u64, + + /// RPC version. + pub jsonrpc: Arc, + + /// The parameters required to fulfill this request. + #[serde(flatten)] + pub param: ResponseParam, +} + +impl Response { + /// Create a new instance. + pub fn new(id: u64, param: ResponseParam) -> Self { + Self { + id, + jsonrpc: JSON_RPC_VERSION.clone(), + param, + } + } + + /// Validates the parameters. + pub fn validate(&self) -> Result<(), ValidationError> { + if self.jsonrpc.as_ref() != JSON_RPC_VERSION_STR { + return Err(ValidationError::JsonRpcVersion); + } + + Ok(()) + } +} + +/* +#[cfg(test)] +mod tests { + use anyhow::Result; + + use params::session_propose::{Proposer, SessionProposeRequest}; + use params::shared_types::{Metadata, Namespace, Namespaces, Relay}; + + use super::*; + + #[test] + fn session_propose() -> Result<()> { + let payload = Payload::Request(Message::new( + 1669395307407398, + RequestParam::SessionPropose(SessionProposeRequest { + relays: vec![Relay { + protocol: "irn".to_string(), + ..Default::default() + }], + proposer: Proposer { + public_key: "dummy_key".to_string(), + metadata: Metadata { + url: "dummy_url".to_string(), + ..Default::default() + }, + }, + required_namespaces: Namespaces { + eip155: Some(Namespace { + chains: vec!["eip155:5".to_string()], + methods: vec![ + "eth_sendTransaction".to_string(), + "eth_signTransaction".to_string(), + "eth_sign".to_string(), + "personal_sign".to_string(), + "eth_signTypedData".to_string(), + ], + events: vec!["chainChanged".to_string(), "accountsChanged".to_string()], + ..Default::default() + }), + ..Default::default() + }, + }), + )); + + let serialized = serde_json::to_string(&payload).unwrap(); + + assert_eq!( + r#"{"id":1669395307407398,"jsonrpc":"2.0","method":"wc_sessionPropose","params":{"relays":[{"protocol":"irn"}],"proposer":{"publicKey":"dummy_key","metadata":{"description":"","url":"dummy_url","icons":[],"name":""}},"requiredNamespaces":{"eip155":{"chains":["eip155:5"],"methods":["eth_sendTransaction","eth_signTransaction","eth_sign","personal_sign","eth_signTypedData"],"events":["chainChanged","accountsChanged"]}}}}"#, + serialized, + ); + + let deserialized: Payload = serde_json::from_str(&serialized)?; + assert_eq!(&payload, &deserialized); + + Ok(()) + } +} +*/ diff --git a/sign_api/src/rpc/params.rs b/sign_api/src/rpc/params.rs new file mode 100644 index 0000000..461541e --- /dev/null +++ b/sign_api/src/rpc/params.rs @@ -0,0 +1,184 @@ +pub(super) mod session_delete; +pub(super) mod session_event; +pub(super) mod session_extend; +pub(super) mod session_ping; +pub(super) mod session_propose; +pub(super) mod session_request; +pub(super) mod session_settle; +pub(super) mod session_update; +pub(super) mod shared_types; + +pub use session_delete::*; +pub use session_event::*; +pub use session_extend::*; +pub use session_ping::*; +pub use session_propose::*; +pub use session_request::*; +pub use session_settle::*; +pub use session_update::*; +pub use shared_types::*; + +use anyhow::Result; +use paste::paste; +pub use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub trait RelayProtocolMetadata { + fn irn_metadata(&self) -> IrnMetadata; +} + +pub trait RelayProtocolHelpers { + type Params; + + fn irn_try_from_tag(value: Value, tag: u32) -> Result; +} + +pub struct IrnMetadata { + pub tag: u32, + pub ttl: u64, + pub prompt: bool, +} + +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>]::SessionUpdate(_) => session_update::[], + [<$param_type>]::SessionExtend(_) => session_extend::[], + [<$param_type>]::SessionRequest(_) => session_request::[], + [<$param_type>]::SessionEvent(_) => session_event::[], + [<$param_type>]::SessionDelete(_) => session_delete::[], + [<$param_type>]::SessionPing(_) => session_ping::[], + } + } + } + } + } +} + +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 { + if tag == session_propose::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionPropose(serde_json::from_value(value)?)) + } else if tag == session_settle::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionSettle(serde_json::from_value(value)?)) + } else if tag == session_update::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionUpdate(serde_json::from_value(value)?)) + } else if tag == session_extend::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionExtend(serde_json::from_value(value)?)) + } else if tag == session_request::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionRequest(serde_json::from_value(value)?)) + } else if tag == session_event::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionEvent(serde_json::from_value(value)?)) + } else if tag == session_delete::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionDelete(serde_json::from_value(value)?)) + } else if tag == session_ping::IRN_RESPONSE_METADATA.tag { + Ok(Self::SessionPing(serde_json::from_value(value)?)) + } else { + anyhow::bail!("tag={tag}, does not match Sign API methods") + } + } + } + } + }; +} + +#[derive(Debug, Serialize, Eq, Deserialize, Clone, PartialEq)] +#[serde(tag = "method", content = "params")] +pub enum RequestParam { + #[serde(rename = "wc_sessionPropose")] + SessionPropose(SessionProposeRequest), + #[serde(rename = "wc_sessionSettle")] + SessionSettle(SessionSettleRequest), + #[serde(rename = "wc_sessionUpdate")] + SessionUpdate(SessionUpdateRequest), + #[serde(rename = "wc_sessionExtend")] + SessionExtend(SessionExtendRequest), + #[serde(rename = "wc_sessionRequest")] + SessionRequest(SessionRequestRequest), + #[serde(rename = "wc_sessionEvent")] + SessionEvent(SessionEventRequest), + #[serde(rename = "wc_sessionDelete")] + SessionDelete(SessionDeleteRequest), + #[serde(rename = "wc_sessionPing")] + SessionPing(()), +} +impl_relay_protocol_metadata!(RequestParam, request); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResponseParam { + /// A response with a result. + #[serde(rename = "result")] + Success(Value), + + /// A response for a failed request. + #[serde(rename = "error")] + Err(Value), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseParamSuccess { + SessionPropose(SessionProposeResponse), + SessionSettle(bool), + SessionUpdate(bool), + SessionExtend(bool), + SessionRequest(bool), + SessionEvent(bool), + SessionDelete(bool), + SessionPing(bool), +} +impl_relay_protocol_metadata!(ResponseParamSuccess, response); +impl_relay_protocol_helpers!(ResponseParamSuccess); + +impl TryFrom for ResponseParam { + type Error = anyhow::Error; + + fn try_from(value: ResponseParamSuccess) -> Result { + Ok(Self::Success(serde_json::to_value(value)?)) + } +} + +/// The documentation states that both fields are required. +/// However, on session expiry error, "empty" error is received. +#[derive(Debug, Clone, Eq, Serialize, Deserialize, PartialEq)] +pub struct ErrorParams { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub message: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ResponseParamError { + SessionPropose(ErrorParams), + SessionSettle(ErrorParams), + SessionUpdate(ErrorParams), + SessionExtend(ErrorParams), + SessionRequest(ErrorParams), + SessionEvent(ErrorParams), + SessionDelete(ErrorParams), + SessionPing(ErrorParams), +} +impl_relay_protocol_metadata!(ResponseParamError, response); +impl_relay_protocol_helpers!(ResponseParamError); + +impl TryFrom for ResponseParam { + type Error = anyhow::Error; + + fn try_from(value: ResponseParamError) -> Result { + Ok(Self::Err(serde_json::to_value(value)?)) + } +} diff --git a/sign_api/src/rpc/params/session_delete.rs b/sign_api/src/rpc/params/session_delete.rs new file mode 100644 index 0000000..dc7c93c --- /dev/null +++ b/sign_api/src/rpc/params/session_delete.rs @@ -0,0 +1,22 @@ +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +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, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionDeleteRequest { + pub code: i64, + pub message: String, +} diff --git a/sign_api/src/rpc/params/session_event.rs b/sign_api/src/rpc/params/session_event.rs new file mode 100644 index 0000000..16b03f9 --- /dev/null +++ b/sign_api/src/rpc/params/session_event.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +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, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Event { + name: String, + data: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionEventRequest { + event: Event, + chain_id: String, +} diff --git a/sign_api/src/rpc/params/session_extend.rs b/sign_api/src/rpc/params/session_extend.rs new file mode 100644 index 0000000..9120f28 --- /dev/null +++ b/sign_api/src/rpc/params/session_extend.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +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, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionExtendRequest { + pub expiry: u64, +} diff --git a/sign_api/src/rpc/params/session_ping.rs b/sign_api/src/rpc/params/session_ping.rs new file mode 100644 index 0000000..5ab3237 --- /dev/null +++ b/sign_api/src/rpc/params/session_ping.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +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, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionPingRequest {} diff --git a/sign_api/src/rpc/params/session_propose.rs b/sign_api/src/rpc/params/session_propose.rs new file mode 100644 index 0000000..e656251 --- /dev/null +++ b/sign_api/src/rpc/params/session_propose.rs @@ -0,0 +1,37 @@ +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Metadata, Namespaces, Relay}; + +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, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Proposer { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeRequest { + pub relays: Vec, + pub proposer: Proposer, + pub required_namespaces: Namespaces, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionProposeResponse { + pub relay: Relay, + pub responder_public_key: String, +} diff --git a/sign_api/src/rpc/params/session_request.rs b/sign_api/src/rpc/params/session_request.rs new file mode 100644 index 0000000..29449cd --- /dev/null +++ b/sign_api/src/rpc/params/session_request.rs @@ -0,0 +1,29 @@ +use serde::{Deserialize, Serialize}; + +use super::IrnMetadata; + +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, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct Request { + method: String, + params: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionRequestRequest { + pub request: Request, + pub chain_id: String, +} diff --git a/sign_api/src/rpc/params/session_settle.rs b/sign_api/src/rpc/params/session_settle.rs new file mode 100644 index 0000000..4794358 --- /dev/null +++ b/sign_api/src/rpc/params/session_settle.rs @@ -0,0 +1,54 @@ +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Metadata, Relay}; + +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, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Controller { + pub public_key: String, + pub metadata: Metadata, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespaces { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub eip155: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub cosmos: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SettleNamespace { + pub accounts: Vec, + pub methods: Vec, + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub extensions: Option>, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct SessionSettleRequest { + pub relay: Relay, + pub controller: Controller, + pub namespaces: SettleNamespaces, + /// uSecs contrary to what documentation says (secs). + pub expiry: u64, +} diff --git a/sign_api/src/rpc/params/session_update.rs b/sign_api/src/rpc/params/session_update.rs new file mode 100644 index 0000000..ad5aba7 --- /dev/null +++ b/sign_api/src/rpc/params/session_update.rs @@ -0,0 +1,21 @@ +use serde::{Deserialize, Serialize}; + +use super::{IrnMetadata, Namespaces}; + +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, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SessionUpdateRequest { + pub namespaces: Namespaces, +} diff --git a/sign_api/src/rpc/params/shared_types.rs b/sign_api/src/rpc/params/shared_types.rs new file mode 100644 index 0000000..4ced73d --- /dev/null +++ b/sign_api/src/rpc/params/shared_types.rs @@ -0,0 +1,132 @@ +use anyhow::{Ok, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Metadata { + pub description: String, + pub url: String, + pub icons: Vec, + pub name: String, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +pub struct Relay { + pub protocol: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub data: Option, +} + +#[derive(Debug, Serialize, PartialEq, Eq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Namespace { + pub chains: Vec, + pub methods: Vec, + pub events: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub extensions: Option>, +} + +impl Namespace { + pub fn supported(&self, required: &Namespace) -> Result<()> { + if !required + .chains + .iter() + .all(|item| self.chains.contains(item)) + { + return Err(anyhow::anyhow!( + "Chain/chains not supported, actual: {:?}, expected: {:?}", + self.chains, + required.chains, + )); + } + + if !required + .methods + .iter() + .all(|item| self.methods.contains(item)) + { + return Err(anyhow::anyhow!( + "Method/methods not supported, actual: {:?}, expected: {:?}", + self.methods, + required.methods, + )); + } + + if !required + .events + .iter() + .all(|item| self.events.contains(item)) + { + return Err(anyhow::anyhow!( + "Event/events not supported, actual: {:?}, expected: {:?}", + self.events, + required.events, + )); + } + + match (&self.extensions, &required.extensions) { + (Some(this), Some(other)) => { + if !other.iter().all(|item| this.contains(item)) { + return Err(anyhow::anyhow!( + "Extension/extensions not supported, actual: {:?}, expected: {:?}", + this, + other, + )); + } + } + (Some(other), None) => { + return Err(anyhow::anyhow!( + "Extension/extensions not supported, actual: , expected: {:?}", + other, + )); + } + (None, Some(_)) | (None, None) => {} + } + + Ok(()) + } +} + +#[derive(Debug, Serialize, Eq, PartialEq, Deserialize, Clone, Default)] +#[serde(rename_all = "camelCase")] +pub struct Namespaces { + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub eip155: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default)] + pub cosmos: Option, +} + +impl Namespaces { + pub fn supported(&self, required: &Namespaces) -> Result<()> { + if self.eip155.is_none() && self.cosmos.is_none() { + return Err(anyhow::anyhow!("No namespaces found")); + } + + match (&required.eip155, &self.eip155) { + (Some(other), Some(this)) => { + return this.supported(other); + } + (Some(_), None) => { + return Err(anyhow::anyhow!("eip155 namespace is required but missing")); + } + (None, Some(_)) | (None, None) => {} + } + + match (&required.cosmos, &self.cosmos) { + (Some(other), Some(this)) => { + return this.supported(other); + } + (Some(_), None) => { + return Err(anyhow::anyhow!("Cosmos namespace is required but missing")); + } + (None, Some(_)) | (None, None) => {} + } + + Ok(()) + } +}