diff --git a/relay_rpc/Cargo.toml b/relay_rpc/Cargo.toml index 3b5570b..976281f 100644 --- a/relay_rpc/Cargo.toml +++ b/relay_rpc/Cargo.toml @@ -16,3 +16,4 @@ rand = "0.7" chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } regex = "1.7" once_cell = "1.16" +jsonwebtoken = "8.1" diff --git a/relay_rpc/src/auth.rs b/relay_rpc/src/auth.rs index 09c9ed9..fde229c 100644 --- a/relay_rpc/src/auth.rs +++ b/relay_rpc/src/auth.rs @@ -1,5 +1,8 @@ +#[cfg(test)] +mod tests; + use { - crate::domain::{AuthSubject, DecodedClientId}, + crate::domain::{AuthSubject, ClientId, ClientIdDecodingError, DecodedClientId}, chrono::{DateTime, Utc}, ed25519_dalek::{ed25519::signature::Signature, Keypair, Signer}, serde::{Deserialize, Serialize}, @@ -114,12 +117,28 @@ pub struct JwtClaims<'a> { } impl<'a> JwtClaims<'a> { - pub fn is_valid(&self, aud: &HashSet, time_leeway: impl Into>) -> bool { + pub fn validate( + &self, + aud: &HashSet, + time_leeway: impl Into>, + ) -> Result<(), JwtVerificationError> { let time_leeway = time_leeway .into() .unwrap_or(JWT_VALIDATION_TIME_LEEWAY_SECS); let now = Utc::now().timestamp(); - (now + time_leeway) >= self.iat && (now - time_leeway) <= self.exp && aud.contains(self.aud) + + if now - time_leeway > self.exp { + return Err(JwtVerificationError::Expired); + } + + if now + time_leeway < self.iat { + return Err(JwtVerificationError::NotYetValid); + } + + if !aud.contains(self.aud) { + return Err(JwtVerificationError::InvalidAudience); + } + Ok(()) } } @@ -170,3 +189,125 @@ pub fn encode_auth_token( Ok(SerializedAuthToken(format!("{message}.{signature}"))) } + +#[derive(Debug, thiserror::Error)] +pub enum JwtVerificationError { + #[error("Invalid format")] + Format, + + #[error("Invalid encoding")] + Encoding, + + #[error("Invalid JWT signing algorithm")] + Header, + + #[error("JWT Token is expired")] + Expired, + + #[error("JWT Token is not yet valid")] + NotYetValid, + + #[error("Invalid audience")] + InvalidAudience, + + #[error("Invalid signature")] + Signature, + + #[error("Invalid JSON")] + Serialization, + + #[error("Invalid issuer DID prefix")] + IssuerPrefix, + + #[error("Invalid issuer DID method")] + IssuerMethod, + + #[error("Invalid issuer format")] + IssuerFormat, + + #[error(transparent)] + PubKey(#[from] ClientIdDecodingError), +} + +#[derive(Debug)] +pub struct Jwt(pub String); + +impl Jwt { + pub fn decode(&self, aud: &HashSet) -> Result { + let mut parts = self.0.splitn(3, JWT_DELIMITER); + + let (Some(header), Some(claims)) = (parts.next(), parts.next()) else { + return Err(JwtVerificationError::Format); + }; + + let decoder = &data_encoding::BASE64URL_NOPAD; + + let header_len = decoder + .decode_len(header.len()) + .map_err(|_| JwtVerificationError::Encoding)?; + let claims_len = decoder + .decode_len(claims.len()) + .map_err(|_| JwtVerificationError::Encoding)?; + + let mut output = vec![0u8; header_len.max(claims_len)]; + + // Decode header. + data_encoding::BASE64URL_NOPAD + .decode_mut(header.as_bytes(), &mut output[..header_len]) + .map_err(|_| JwtVerificationError::Encoding)?; + + { + let header = serde_json::from_slice::(&output[..header_len]) + .map_err(|_| JwtVerificationError::Serialization)?; + + if !header.is_valid() { + return Err(JwtVerificationError::Header); + } + } + + // Decode claims. + data_encoding::BASE64URL_NOPAD + .decode_mut(claims.as_bytes(), &mut output[..claims_len]) + .map_err(|_| JwtVerificationError::Encoding)?; + + let claims = serde_json::from_slice::(&output[..claims_len]) + .map_err(|_| JwtVerificationError::Serialization)?; + + // Basic token validation: `iat`, `exp` and `aud`. + claims.validate(aud, None)?; + + let did_key = claims + .iss + .strip_prefix(DID_PREFIX) + .ok_or(JwtVerificationError::IssuerPrefix)? + .strip_prefix(DID_DELIMITER) + .ok_or(JwtVerificationError::IssuerFormat)? + .strip_prefix(DID_METHOD) + .ok_or(JwtVerificationError::IssuerMethod)? + .strip_prefix(DID_DELIMITER) + .ok_or(JwtVerificationError::IssuerFormat)?; + + let pub_key = did_key.parse::()?; + + let mut parts = self.0.rsplitn(2, JWT_DELIMITER); + + let (Some(signature), Some(message)) = (parts.next(), parts.next()) else { + return Err(JwtVerificationError::Format); + }; + + let key = jsonwebtoken::DecodingKey::from_ed_der(pub_key.as_ref()); + + // Finally, verify signature. + let sig_result = jsonwebtoken::crypto::verify( + signature, + message.as_bytes(), + &key, + jsonwebtoken::Algorithm::EdDSA, + ); + + match sig_result { + Ok(true) => Ok(pub_key.into()), + _ => Err(JwtVerificationError::Signature), + } + } +} diff --git a/relay_rpc/src/auth/tests.rs b/relay_rpc/src/auth/tests.rs new file mode 100644 index 0000000..b84a4e8 --- /dev/null +++ b/relay_rpc/src/auth/tests.rs @@ -0,0 +1,131 @@ +use { + crate::{ + auth::{AuthToken, Jwt, JwtVerificationError, JWT_VALIDATION_TIME_LEEWAY_SECS}, + domain::{ClientIdDecodingError, DecodedAuthSubject}, + }, + ed25519_dalek::Keypair, + std::{collections::HashSet, time::Duration}, +}; + +#[test] +fn token_validation() { + let aud = HashSet::from(["wss://relay.walletconnect.com".to_owned()]); + + // Invalid signature. + let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtvZEhad25lVlJTaHRhTGY4SktZa3hwREdwMXZHWm5wR21kQnBYOE0yZXh4SCIsInN1YiI6ImM0NzlmZTVkYzQ2NGU3NzFlNzhiMTkzZDIzOWE2NWI1OGQyNzhjYWQxYzM0YmZiMGI1NzE2ZTViYjUxNDkyOGUiLCJhdWQiOiJ3c3M6Ly9yZWxheS53YWxsZXRjb25uZWN0LmNvbSIsImlhdCI6MTY1NjkxMDA5NywiZXhwIjo0ODEyNjcwMDk3fQ.CLryc7bGZ_mBVh-P5p2tDDkjY8m9ji9xZXixJCbLLd4TMBh7F0EkChbWOOUQp4DyXUVK4CN-hxMZgt2xnePUBAx".to_owned()); + assert!(matches!( + jwt.decode(&aud), + Err(JwtVerificationError::Signature) + )); + + // Invalid multicodec header. + let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWt2eDRWVnVCQlBIekVvTERiNWdOQzRyUW1uSnN0YzFib29oS2ZjSlV0OU12NjUiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.ixjxEISufsDpdsp4MRwD4Q100d8s7v4mSlIWIad6q8Nh__768pzPaCAVXQIZLxKPhuJQ92cZi7tVUJtAE1_UCg".to_owned()); + assert!(matches!( + jwt.decode(&aud), + Err(JwtVerificationError::PubKey( + ClientIdDecodingError::Encoding + )) + )); + + // Invalid multicodec base. + let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Onh6Nk1rb2RIWnduZVZSU2h0YUxmOEpLWWt4cERHcDF2R1pucEdtZEJwWDhNMmV4eEgiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.BINvB6JpUyp5Zs7qbIYMv7KybptioYFZP89ZFTMtvdGvEnRpYg70uzwSLdhZB1EPJZIrUMhybfT7Q1DYEqHwDw".to_owned()); + assert!(matches!( + jwt.decode(&aud), + Err(JwtVerificationError::PubKey(ClientIdDecodingError::Base)) + )); + + // Invalid DID prefix. + let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4ZGlkOmtleTp6Nk1rb2RIWnduZVZSU2h0YUxmOEpLWWt4cERHcDF2R1pucEdtZEJwWDhNMmV4eEgiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.GGhlhz7kXCqCTUsn390O_hA9YQDa61d_DDiSVLsa70xrgFrGmjjoWWl1dsZn3RVq4V1IB0P1__NDJ2PK0OMiDA".to_owned()); + assert!(matches!( + jwt.decode(&aud), + Err(JwtVerificationError::IssuerPrefix) + )); + + // Invalid DID method + let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6eGtleTp6Nk1rb2RIWnduZVZSU2h0YUxmOEpLWWt4cERHcDF2R1pucEdtZEJwWDhNMmV4eEgiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.rogEwjJLQFwbDm4psUty7MPkHrCrNiXxpwEYZ2nctppmF7MYvC3g7URZNYkKxMbFtNZ1hFCwsr1peEu3pVeJCg".to_owned()); + assert!(matches!( + jwt.decode(&aud), + Err(JwtVerificationError::IssuerMethod) + )); + + // Invalid issuer base58. + let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtvZEhad25lVlJTaHRhTGY4SktZa3hwREdwMXZHWm5wR21kQnBYOE0yZXh4SGwiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.nLdxz4f6yJ8HsWZJUvpSHjFjoat4PfJav-kyqdHj6JXcX5SyDvp3QNB9doyzRWb9jpbA36Av0qn4kqLl-pGuBg".to_owned()); + assert!(matches!( + jwt.decode(&aud), + Err(JwtVerificationError::PubKey( + ClientIdDecodingError::Encoding + )) + )); + + let keypair = Keypair::generate(&mut rand::thread_rng()); + + // IAT in future. + let jwt = AuthToken::new(DecodedAuthSubject::generate()) + .iat(chrono::Utc::now() + chrono::Duration::hours(1)) + .as_jwt(&keypair) + .unwrap(); + assert!(matches!( + Jwt(jwt.into()).decode(&aud), + Err(JwtVerificationError::NotYetValid) + )); + + // IAT leeway, valid. + let jwt = AuthToken::new(DecodedAuthSubject::generate()) + .iat(chrono::Utc::now() + chrono::Duration::seconds(JWT_VALIDATION_TIME_LEEWAY_SECS)) + .as_jwt(&keypair) + .unwrap(); + assert!(matches!(Jwt(jwt.into()).decode(&aud), Ok(_))); + + // IAT leeway, invalid. + let jwt = AuthToken::new(DecodedAuthSubject::generate()) + .iat(chrono::Utc::now() + chrono::Duration::seconds(JWT_VALIDATION_TIME_LEEWAY_SECS + 1)) + .as_jwt(&keypair) + .unwrap(); + assert!(matches!( + Jwt(jwt.into()).decode(&aud), + Err(JwtVerificationError::NotYetValid) + )); + + // Past expiration. + let jwt = AuthToken::new(DecodedAuthSubject::generate()) + .iat(chrono::Utc::now() - chrono::Duration::hours(2)) + .ttl(Duration::from_secs(3600)) + .as_jwt(&keypair) + .unwrap(); + assert!(matches!( + Jwt(jwt.into()).decode(&aud), + Err(JwtVerificationError::Expired) + )); + + // Expiration leeway, valid. + let jwt = AuthToken::new(DecodedAuthSubject::generate()) + .iat(chrono::Utc::now() - chrono::Duration::seconds(3600 + JWT_VALIDATION_TIME_LEEWAY_SECS)) + .ttl(Duration::from_secs(3600)) + .as_jwt(&keypair) + .unwrap(); + assert!(matches!(Jwt(jwt.into()).decode(&aud), Ok(_))); + + // Expiration leeway, invalid. + let jwt = AuthToken::new(DecodedAuthSubject::generate()) + .iat( + chrono::Utc::now() + - chrono::Duration::seconds(3600 + JWT_VALIDATION_TIME_LEEWAY_SECS + 1), + ) + .ttl(Duration::from_secs(3600)) + .as_jwt(&keypair) + .unwrap(); + assert!(matches!( + Jwt(jwt.into()).decode(&aud), + Err(JwtVerificationError::Expired) + )); + + // Invalid aud. + let jwt = AuthToken::new(DecodedAuthSubject::generate()) + .aud("wss://not.relay.walletconnect.com") + .as_jwt(&keypair) + .unwrap(); + assert!(matches!( + Jwt(jwt.into()).decode(&aud), + Err(JwtVerificationError::InvalidAudience) + )); +}