Skip to content

Commit

Permalink
feat: added jwt decoding (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
Rakowskiii committed Mar 16, 2023
1 parent b44beee commit ced99e7
Show file tree
Hide file tree
Showing 3 changed files with 276 additions and 3 deletions.
1 change: 1 addition & 0 deletions relay_rpc/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
147 changes: 144 additions & 3 deletions relay_rpc/src/auth.rs
Original file line number Diff line number Diff line change
@@ -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},
Expand Down Expand Up @@ -114,12 +117,28 @@ pub struct JwtClaims<'a> {
}

impl<'a> JwtClaims<'a> {
pub fn is_valid(&self, aud: &HashSet<String>, time_leeway: impl Into<Option<i64>>) -> bool {
pub fn validate(
&self,
aud: &HashSet<String>,
time_leeway: impl Into<Option<i64>>,
) -> 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(())
}
}

Expand Down Expand Up @@ -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<String>) -> Result<ClientId, JwtVerificationError> {
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::<JwtHeader>(&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::<JwtClaims>(&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::<DecodedClientId>()?;

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),
}
}
}
131 changes: 131 additions & 0 deletions relay_rpc/src/auth/tests.rs
Original file line number Diff line number Diff line change
@@ -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)
));
}

0 comments on commit ced99e7

Please sign in to comment.