diff --git a/relay_rpc/Cargo.toml b/relay_rpc/Cargo.toml index 976281f..d3b03c3 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -17,3 +17,6 @@ chrono = { version = "0.4", default-features = false, features = ["std", "clock" regex = "1.7" once_cell = "1.16" jsonwebtoken = "8.1" +k256 = "0.13.0" +sha3 = "0.10.6" +hex = "0.4.3" \ No newline at end of file diff --git a/relay_rpc/src/auth.rs b/relay_rpc/src/auth.rs index fde229c..8d02601 100644 --- a/relay_rpc/src/auth.rs +++ b/relay_rpc/src/auth.rs @@ -1,6 +1,9 @@ #[cfg(test)] mod tests; +pub mod cacao; +pub mod did; + use { crate::domain::{AuthSubject, ClientId, ClientIdDecodingError, DecodedClientId}, chrono::{DateTime, Utc}, diff --git a/relay_rpc/src/auth/cacao/header.rs b/relay_rpc/src/auth/cacao/header.rs new file mode 100644 index 0000000..fbc1c76 --- /dev/null +++ b/relay_rpc/src/auth/cacao/header.rs @@ -0,0 +1,18 @@ +use { + super::CacaoError, + serde::{Deserialize, Serialize}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] +pub struct Header { + pub t: String, +} + +impl Header { + pub fn is_valid(&self) -> Result<(), CacaoError> { + match self.t.as_str() { + "eip4361" => Ok(()), + _ => Err(CacaoError::Header), + } + } +} diff --git a/relay_rpc/src/auth/cacao/mod.rs b/relay_rpc/src/auth/cacao/mod.rs new file mode 100644 index 0000000..4eb1942 --- /dev/null +++ b/relay_rpc/src/auth/cacao/mod.rs @@ -0,0 +1,133 @@ +use { + self::{header::Header, payload::Payload, signature::Signature}, + core::fmt::Debug, + serde::{Deserialize, Serialize}, + std::fmt::{Display, Write as _}, + thiserror::Error as ThisError, +}; + +pub mod header; +pub mod payload; +pub mod signature; + +/// Errors that can occur during JWT verification +#[derive(Debug, ThisError)] +pub enum CacaoError { + #[error("Invalid header")] + Header, + + #[error("Invalid or missing identity key in payload resources")] + PayloadIdentityKey, + + #[error("Invalid payload resources")] + PayloadResources, + + #[error("Unsupported signature type")] + UnsupportedSignature, + + #[error("Unable to verify")] + Verification, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum Version { + V1 = 1, +} + +impl<'de> Deserialize<'de> for Version { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let version = String::deserialize(deserializer)?; + match version.as_str() { + "1" => Ok(Version::V1), + _ => Err(serde::de::Error::custom("Invalid version")), + } + } +} + +impl Serialize for Version { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&format!("{}", *self as u8)) + } +} + +impl Display for Version { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", *self as u8) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] +pub struct Cacao { + pub h: Header, + pub p: Payload, + pub s: Signature, +} + +impl Cacao { + const ETHEREUM: &'static str = "Ethereum"; + + pub fn verify(&self) -> Result { + self.p.is_valid()?; + self.h.is_valid()?; + self.s.verify(self) + } + + pub fn siwe_message(&self) -> Result { + self.caip122_message(Self::ETHEREUM) + } + + pub fn caip122_message(&self, chain_name: &str) -> Result { + let mut message = format!( + "{} wants you to sign in with your {} account:\n{}\n", + self.p.domain, + chain_name, + self.p.address()? + ); + + if let Some(statement) = &self.p.statement { + let _ = write!(message, "\n{}\n", statement); + } + + let _ = write!( + message, + "\nURI: {}\nVersion: {}\nChain ID: {}\nNonce: {}\nIssued At: {}", + self.p.aud, + self.p.version, + self.p.chain_id()?, + self.p.nonce, + self.p.iat + ); + + if let Some(exp) = &self.p.exp { + let _ = write!(message, "\nExpiration Time: {}", exp); + } + + if let Some(nbf) = &self.p.nbf { + let _ = write!(message, "\nNot Before: {}", nbf); + } + + if let Some(request_id) = &self.p.request_id { + let _ = write!(message, "\nRequest ID: {}", request_id); + } + + if let Some(resources) = &self.p.resources { + if !resources.is_empty() { + let _ = write!(message, "\nResources:"); + resources.iter().for_each(|resource| { + let _ = write!(message, "\n- {}", resource); + }); + } + } + + Ok(message) + } +} + +#[cfg(test)] +mod tests; diff --git a/relay_rpc/src/auth/cacao/payload.rs b/relay_rpc/src/auth/cacao/payload.rs new file mode 100644 index 0000000..ab8bf3c --- /dev/null +++ b/relay_rpc/src/auth/cacao/payload.rs @@ -0,0 +1,87 @@ +use { + super::{CacaoError, Version}, + crate::auth::did::{extract_did_data, DID_METHOD_KEY}, + serde::{Deserialize, Serialize}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] +pub struct Payload { + pub domain: String, + pub iss: String, + pub statement: Option, + pub aud: String, + pub version: Version, + pub nonce: String, + pub iat: String, + pub exp: Option, + pub nbf: Option, + pub request_id: Option, + pub resources: Option>, +} + +impl Payload { + const ISS_DELIMITER: &'static str = ":"; + const ISS_POSITION_OF_ADDRESS: usize = 4; + const ISS_POSITION_OF_NAMESPACE: usize = 2; + const ISS_POSITION_OF_REFERENCE: usize = 3; + + /// TODO: write valdation + pub fn is_valid(&self) -> Result { + Ok(true) + } + + pub fn address(&self) -> Result { + self.iss + .split(Self::ISS_DELIMITER) + .nth(Self::ISS_POSITION_OF_ADDRESS) + .ok_or(CacaoError::PayloadResources) + .map(|s| s.to_string()) + } + + pub fn namespace(&self) -> Result { + self.iss + .split(Self::ISS_DELIMITER) + .nth(Self::ISS_POSITION_OF_NAMESPACE) + .ok_or(CacaoError::PayloadResources) + .map(|s| s.to_string()) + } + + pub fn chain_id_reference(&self) -> Result { + Ok(format!( + "{}{}{}", + self.namespace()?, + Self::ISS_DELIMITER, + self.chain_id()? + )) + } + + pub fn chain_id(&self) -> Result { + self.iss + .split(Self::ISS_DELIMITER) + .nth(Self::ISS_POSITION_OF_REFERENCE) + .ok_or(CacaoError::PayloadResources) + .map(|s| s.to_string()) + } + + pub fn caip_10_address(&self) -> Result { + Ok(format!( + "{}{}{}", + self.chain_id_reference()?, + Self::ISS_DELIMITER, + self.address()? + ) + .to_lowercase()) + } + + pub fn identity_key(&self) -> Result { + let resources = self + .resources + .as_ref() + .ok_or(CacaoError::PayloadResources)?; + let did_key = resources.first().ok_or(CacaoError::PayloadIdentityKey)?; + + extract_did_data(did_key, DID_METHOD_KEY) + .map(|data| data.to_string()) + .map_err(|_| CacaoError::PayloadIdentityKey) + } +} diff --git a/relay_rpc/src/auth/cacao/signature.rs b/relay_rpc/src/auth/cacao/signature.rs new file mode 100644 index 0000000..49e86a4 --- /dev/null +++ b/relay_rpc/src/auth/cacao/signature.rs @@ -0,0 +1,75 @@ +use { + super::{Cacao, CacaoError}, + serde::{Deserialize, Serialize}, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize, Hash)] +pub struct Signature { + pub t: String, + pub s: String, +} + +impl Signature { + pub fn verify(&self, cacao: &Cacao) -> Result { + match self.t.as_str() { + "eip191" => Eip191.verify(&cacao.s.s, &cacao.p.address()?, &cacao.siwe_message()?), + // "eip1271" => Eip1271.verify(), TODO: How to accces our RPC? + _ => Err(CacaoError::UnsupportedSignature), + } + } +} + +pub struct Eip191; + +impl Eip191 { + pub fn eip191_bytes(&self, message: &str) -> Vec { + format!( + "\u{0019}Ethereum Signed Message:\n{}{}", + message.as_bytes().len(), + message + ) + .into() + } + + fn verify(&self, signature: &str, address: &str, message: &str) -> Result { + use { + k256::ecdsa::{RecoveryId, Signature as Sig, VerifyingKey}, + sha3::{Digest, Keccak256}, + }; + + let signature_bytes = hex::decode(guarantee_no_hex_prefix(signature)) + .map_err(|_| CacaoError::Verification)?; + + let sig = Sig::try_from(&signature_bytes[..64]).map_err(|_| CacaoError::Verification)?; + let recovery_id = RecoveryId::try_from(&signature_bytes[64] % 27) + .map_err(|_| CacaoError::Verification)?; + + let recovered_key = VerifyingKey::recover_from_digest( + Keccak256::new_with_prefix(&self.eip191_bytes(message)), + &sig, + recovery_id, + ) + .map_err(|_| CacaoError::Verification)?; + + let add = &Keccak256::default() + .chain_update(&recovered_key.to_encoded_point(false).as_bytes()[1..]) + .finalize()[12..]; + + let address_encoded = hex::encode(add); + + if address_encoded.to_lowercase() != guarantee_no_hex_prefix(address).to_lowercase() { + Err(CacaoError::Verification) + } else { + Ok(true) + } + } +} + +/// Remove the 0x prefix from a hex string +fn guarantee_no_hex_prefix(s: &str) -> &str { + if let Some(stripped) = s.strip_prefix("0x") { + stripped + } else { + s + } +} diff --git a/relay_rpc/src/auth/cacao/tests.rs b/relay_rpc/src/auth/cacao/tests.rs new file mode 100644 index 0000000..4e3a206 --- /dev/null +++ b/relay_rpc/src/auth/cacao/tests.rs @@ -0,0 +1,46 @@ +use crate::auth::cacao::Cacao; + +/// Test that we can verify a Cacao +#[test] +fn cacao_verify_success() { + let cacao_serialized = r#"{ + "h": { + "t": "eip4361" + }, + "p": { + "iss": "did:pkh:eip155:1:0xf457f233ab23f863cabc383ebb37b29d8929a17a", + "domain": "http://10.0.2.2:8080", + "aud": "http://10.0.2.2:8080", + "version": "1", + "nonce": "[B@c3772c7", + "iat": "2023-01-17T12:15:05+01:00", + "resources": [ + "did:key:z6MkkG9nM8ksS37sq5mgeoCn5kihLkWANcm9pza5WTkq3tWZ" + ] + }, + "s": { + "t": "eip191", + "s": "0x1b39982707c70c95f4676e7386052a07b47ecc073b3e9cf47b64b579687d3f68181d48fa9e926ad591ba6954f1a70c597d0772a800bed5fa906384fcd83bcf4f1b" + } + } "#; + let cacao: Cacao = serde_json::from_str(cacao_serialized).unwrap(); + let result = cacao.verify(); + assert!(result.is_ok()); + assert!(result.map_err(|_| false).unwrap()); + + let identity_key = cacao.p.identity_key(); + assert!(identity_key.is_ok()); +} + +/// Test that we can verify a Cacao with uppercase address +#[test] +fn cacao_without_lowercase_address_verify_success() { + let cacao_serialized = r#"{"h":{"t":"eip4361"},"p":{"iss":"did:pkh:eip155:1:0xbD4D1935165012e7D29919dB8717A5e670a1a5b1","domain":"https://staging.keys.walletconnect.com","aud":"https://staging.keys.walletconnect.com","version":"1","nonce":"07487c09be5535dcbc341d8e35e5c9b4d3539a802089c42c5b1172dd9ed63c64","iat":"2023-01-25T15:08:36.846Z","statement":"Test","resources":["did:key:451cf9b97c64fcca05fbb0d4c40b886c94133653df5a2b6bd97bd29a0bbcdb37"]},"s":{"t":"eip191","s":"0x8496ad1dd1ddd5cb78ac26b62a6bd1c6cfff703ea3b11a9da29cfca112357ace75cac8ee28d114f9e166a6935ee9ed83151819a9e0ee738a0547116b1d978e351b"}}"#; + let cacao: Cacao = serde_json::from_str(cacao_serialized).unwrap(); + let result = cacao.verify(); + assert!(result.is_ok()); + assert!(result.map_err(|_| false).unwrap()); + + let identity_key = cacao.p.identity_key(); + assert!(identity_key.is_ok()); +} diff --git a/relay_rpc/src/auth/did.rs b/relay_rpc/src/auth/did.rs new file mode 100644 index 0000000..3703f99 --- /dev/null +++ b/relay_rpc/src/auth/did.rs @@ -0,0 +1,31 @@ +pub const DID_DELIMITER: &str = ":"; +pub const DID_PREFIX: &str = "did"; +pub const DID_METHOD_KEY: &str = "key"; +pub const DID_METHOD_PKH: &str = "pkh"; + +use thiserror::Error as ThisError; + +#[derive(Debug, ThisError)] +pub enum DidError { + #[error("Invalid issuer DID prefix")] + Prefix, + + #[error("Invalid issuer DID method")] + Method, + + #[error("Invalid issuer format")] + Format, +} + +pub fn extract_did_data<'a>(did: &'a str, method: &'a str) -> Result<&'a str, DidError> { + let data = did + .strip_prefix(DID_PREFIX) + .ok_or(DidError::Prefix)? + .strip_prefix(DID_DELIMITER) + .ok_or(DidError::Format)? + .strip_prefix(method) + .ok_or(DidError::Method)? + .strip_prefix(DID_DELIMITER) + .ok_or(DidError::Format)?; + Ok(data) +}