Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: added jwt decoding #13

Merged
merged 6 commits into from
Mar 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
));
}