Skip to content

Commit ced99e7

Browse files
authored
feat: added jwt decoding (#13)
1 parent b44beee commit ced99e7

File tree

3 files changed

+276
-3
lines changed

3 files changed

+276
-3
lines changed

relay_rpc/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ rand = "0.7"
1616
chrono = { version = "0.4", default-features = false, features = ["std", "clock"] }
1717
regex = "1.7"
1818
once_cell = "1.16"
19+
jsonwebtoken = "8.1"

relay_rpc/src/auth.rs

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
#[cfg(test)]
2+
mod tests;
3+
14
use {
2-
crate::domain::{AuthSubject, DecodedClientId},
5+
crate::domain::{AuthSubject, ClientId, ClientIdDecodingError, DecodedClientId},
36
chrono::{DateTime, Utc},
47
ed25519_dalek::{ed25519::signature::Signature, Keypair, Signer},
58
serde::{Deserialize, Serialize},
@@ -114,12 +117,28 @@ pub struct JwtClaims<'a> {
114117
}
115118

116119
impl<'a> JwtClaims<'a> {
117-
pub fn is_valid(&self, aud: &HashSet<String>, time_leeway: impl Into<Option<i64>>) -> bool {
120+
pub fn validate(
121+
&self,
122+
aud: &HashSet<String>,
123+
time_leeway: impl Into<Option<i64>>,
124+
) -> Result<(), JwtVerificationError> {
118125
let time_leeway = time_leeway
119126
.into()
120127
.unwrap_or(JWT_VALIDATION_TIME_LEEWAY_SECS);
121128
let now = Utc::now().timestamp();
122-
(now + time_leeway) >= self.iat && (now - time_leeway) <= self.exp && aud.contains(self.aud)
129+
130+
if now - time_leeway > self.exp {
131+
return Err(JwtVerificationError::Expired);
132+
}
133+
134+
if now + time_leeway < self.iat {
135+
return Err(JwtVerificationError::NotYetValid);
136+
}
137+
138+
if !aud.contains(self.aud) {
139+
return Err(JwtVerificationError::InvalidAudience);
140+
}
141+
Ok(())
123142
}
124143
}
125144

@@ -170,3 +189,125 @@ pub fn encode_auth_token(
170189

171190
Ok(SerializedAuthToken(format!("{message}.{signature}")))
172191
}
192+
193+
#[derive(Debug, thiserror::Error)]
194+
pub enum JwtVerificationError {
195+
#[error("Invalid format")]
196+
Format,
197+
198+
#[error("Invalid encoding")]
199+
Encoding,
200+
201+
#[error("Invalid JWT signing algorithm")]
202+
Header,
203+
204+
#[error("JWT Token is expired")]
205+
Expired,
206+
207+
#[error("JWT Token is not yet valid")]
208+
NotYetValid,
209+
210+
#[error("Invalid audience")]
211+
InvalidAudience,
212+
213+
#[error("Invalid signature")]
214+
Signature,
215+
216+
#[error("Invalid JSON")]
217+
Serialization,
218+
219+
#[error("Invalid issuer DID prefix")]
220+
IssuerPrefix,
221+
222+
#[error("Invalid issuer DID method")]
223+
IssuerMethod,
224+
225+
#[error("Invalid issuer format")]
226+
IssuerFormat,
227+
228+
#[error(transparent)]
229+
PubKey(#[from] ClientIdDecodingError),
230+
}
231+
232+
#[derive(Debug)]
233+
pub struct Jwt(pub String);
234+
235+
impl Jwt {
236+
pub fn decode(&self, aud: &HashSet<String>) -> Result<ClientId, JwtVerificationError> {
237+
let mut parts = self.0.splitn(3, JWT_DELIMITER);
238+
239+
let (Some(header), Some(claims)) = (parts.next(), parts.next()) else {
240+
return Err(JwtVerificationError::Format);
241+
};
242+
243+
let decoder = &data_encoding::BASE64URL_NOPAD;
244+
245+
let header_len = decoder
246+
.decode_len(header.len())
247+
.map_err(|_| JwtVerificationError::Encoding)?;
248+
let claims_len = decoder
249+
.decode_len(claims.len())
250+
.map_err(|_| JwtVerificationError::Encoding)?;
251+
252+
let mut output = vec![0u8; header_len.max(claims_len)];
253+
254+
// Decode header.
255+
data_encoding::BASE64URL_NOPAD
256+
.decode_mut(header.as_bytes(), &mut output[..header_len])
257+
.map_err(|_| JwtVerificationError::Encoding)?;
258+
259+
{
260+
let header = serde_json::from_slice::<JwtHeader>(&output[..header_len])
261+
.map_err(|_| JwtVerificationError::Serialization)?;
262+
263+
if !header.is_valid() {
264+
return Err(JwtVerificationError::Header);
265+
}
266+
}
267+
268+
// Decode claims.
269+
data_encoding::BASE64URL_NOPAD
270+
.decode_mut(claims.as_bytes(), &mut output[..claims_len])
271+
.map_err(|_| JwtVerificationError::Encoding)?;
272+
273+
let claims = serde_json::from_slice::<JwtClaims>(&output[..claims_len])
274+
.map_err(|_| JwtVerificationError::Serialization)?;
275+
276+
// Basic token validation: `iat`, `exp` and `aud`.
277+
claims.validate(aud, None)?;
278+
279+
let did_key = claims
280+
.iss
281+
.strip_prefix(DID_PREFIX)
282+
.ok_or(JwtVerificationError::IssuerPrefix)?
283+
.strip_prefix(DID_DELIMITER)
284+
.ok_or(JwtVerificationError::IssuerFormat)?
285+
.strip_prefix(DID_METHOD)
286+
.ok_or(JwtVerificationError::IssuerMethod)?
287+
.strip_prefix(DID_DELIMITER)
288+
.ok_or(JwtVerificationError::IssuerFormat)?;
289+
290+
let pub_key = did_key.parse::<DecodedClientId>()?;
291+
292+
let mut parts = self.0.rsplitn(2, JWT_DELIMITER);
293+
294+
let (Some(signature), Some(message)) = (parts.next(), parts.next()) else {
295+
return Err(JwtVerificationError::Format);
296+
};
297+
298+
let key = jsonwebtoken::DecodingKey::from_ed_der(pub_key.as_ref());
299+
300+
// Finally, verify signature.
301+
let sig_result = jsonwebtoken::crypto::verify(
302+
signature,
303+
message.as_bytes(),
304+
&key,
305+
jsonwebtoken::Algorithm::EdDSA,
306+
);
307+
308+
match sig_result {
309+
Ok(true) => Ok(pub_key.into()),
310+
_ => Err(JwtVerificationError::Signature),
311+
}
312+
}
313+
}

relay_rpc/src/auth/tests.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
use {
2+
crate::{
3+
auth::{AuthToken, Jwt, JwtVerificationError, JWT_VALIDATION_TIME_LEEWAY_SECS},
4+
domain::{ClientIdDecodingError, DecodedAuthSubject},
5+
},
6+
ed25519_dalek::Keypair,
7+
std::{collections::HashSet, time::Duration},
8+
};
9+
10+
#[test]
11+
fn token_validation() {
12+
let aud = HashSet::from(["wss://relay.walletconnect.com".to_owned()]);
13+
14+
// Invalid signature.
15+
let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtvZEhad25lVlJTaHRhTGY4SktZa3hwREdwMXZHWm5wR21kQnBYOE0yZXh4SCIsInN1YiI6ImM0NzlmZTVkYzQ2NGU3NzFlNzhiMTkzZDIzOWE2NWI1OGQyNzhjYWQxYzM0YmZiMGI1NzE2ZTViYjUxNDkyOGUiLCJhdWQiOiJ3c3M6Ly9yZWxheS53YWxsZXRjb25uZWN0LmNvbSIsImlhdCI6MTY1NjkxMDA5NywiZXhwIjo0ODEyNjcwMDk3fQ.CLryc7bGZ_mBVh-P5p2tDDkjY8m9ji9xZXixJCbLLd4TMBh7F0EkChbWOOUQp4DyXUVK4CN-hxMZgt2xnePUBAx".to_owned());
16+
assert!(matches!(
17+
jwt.decode(&aud),
18+
Err(JwtVerificationError::Signature)
19+
));
20+
21+
// Invalid multicodec header.
22+
let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWt2eDRWVnVCQlBIekVvTERiNWdOQzRyUW1uSnN0YzFib29oS2ZjSlV0OU12NjUiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.ixjxEISufsDpdsp4MRwD4Q100d8s7v4mSlIWIad6q8Nh__768pzPaCAVXQIZLxKPhuJQ92cZi7tVUJtAE1_UCg".to_owned());
23+
assert!(matches!(
24+
jwt.decode(&aud),
25+
Err(JwtVerificationError::PubKey(
26+
ClientIdDecodingError::Encoding
27+
))
28+
));
29+
30+
// Invalid multicodec base.
31+
let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Onh6Nk1rb2RIWnduZVZSU2h0YUxmOEpLWWt4cERHcDF2R1pucEdtZEJwWDhNMmV4eEgiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.BINvB6JpUyp5Zs7qbIYMv7KybptioYFZP89ZFTMtvdGvEnRpYg70uzwSLdhZB1EPJZIrUMhybfT7Q1DYEqHwDw".to_owned());
32+
assert!(matches!(
33+
jwt.decode(&aud),
34+
Err(JwtVerificationError::PubKey(ClientIdDecodingError::Base))
35+
));
36+
37+
// Invalid DID prefix.
38+
let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ4ZGlkOmtleTp6Nk1rb2RIWnduZVZSU2h0YUxmOEpLWWt4cERHcDF2R1pucEdtZEJwWDhNMmV4eEgiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.GGhlhz7kXCqCTUsn390O_hA9YQDa61d_DDiSVLsa70xrgFrGmjjoWWl1dsZn3RVq4V1IB0P1__NDJ2PK0OMiDA".to_owned());
39+
assert!(matches!(
40+
jwt.decode(&aud),
41+
Err(JwtVerificationError::IssuerPrefix)
42+
));
43+
44+
// Invalid DID method
45+
let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6eGtleTp6Nk1rb2RIWnduZVZSU2h0YUxmOEpLWWt4cERHcDF2R1pucEdtZEJwWDhNMmV4eEgiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.rogEwjJLQFwbDm4psUty7MPkHrCrNiXxpwEYZ2nctppmF7MYvC3g7URZNYkKxMbFtNZ1hFCwsr1peEu3pVeJCg".to_owned());
46+
assert!(matches!(
47+
jwt.decode(&aud),
48+
Err(JwtVerificationError::IssuerMethod)
49+
));
50+
51+
// Invalid issuer base58.
52+
let jwt = Jwt("eyJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtvZEhad25lVlJTaHRhTGY4SktZa3hwREdwMXZHWm5wR21kQnBYOE0yZXh4SGwiLCJzdWIiOiJjNDc5ZmU1ZGM0NjRlNzcxZTc4YjE5M2QyMzlhNjViNThkMjc4Y2FkMWMzNGJmYjBiNTcxNmU1YmI1MTQ5MjhlIiwiYXVkIjoid3NzOi8vcmVsYXkud2FsbGV0Y29ubmVjdC5jb20iLCJpYXQiOjE2NTY5MTAwOTcsImV4cCI6NDgxMjY3MDA5N30.nLdxz4f6yJ8HsWZJUvpSHjFjoat4PfJav-kyqdHj6JXcX5SyDvp3QNB9doyzRWb9jpbA36Av0qn4kqLl-pGuBg".to_owned());
53+
assert!(matches!(
54+
jwt.decode(&aud),
55+
Err(JwtVerificationError::PubKey(
56+
ClientIdDecodingError::Encoding
57+
))
58+
));
59+
60+
let keypair = Keypair::generate(&mut rand::thread_rng());
61+
62+
// IAT in future.
63+
let jwt = AuthToken::new(DecodedAuthSubject::generate())
64+
.iat(chrono::Utc::now() + chrono::Duration::hours(1))
65+
.as_jwt(&keypair)
66+
.unwrap();
67+
assert!(matches!(
68+
Jwt(jwt.into()).decode(&aud),
69+
Err(JwtVerificationError::NotYetValid)
70+
));
71+
72+
// IAT leeway, valid.
73+
let jwt = AuthToken::new(DecodedAuthSubject::generate())
74+
.iat(chrono::Utc::now() + chrono::Duration::seconds(JWT_VALIDATION_TIME_LEEWAY_SECS))
75+
.as_jwt(&keypair)
76+
.unwrap();
77+
assert!(matches!(Jwt(jwt.into()).decode(&aud), Ok(_)));
78+
79+
// IAT leeway, invalid.
80+
let jwt = AuthToken::new(DecodedAuthSubject::generate())
81+
.iat(chrono::Utc::now() + chrono::Duration::seconds(JWT_VALIDATION_TIME_LEEWAY_SECS + 1))
82+
.as_jwt(&keypair)
83+
.unwrap();
84+
assert!(matches!(
85+
Jwt(jwt.into()).decode(&aud),
86+
Err(JwtVerificationError::NotYetValid)
87+
));
88+
89+
// Past expiration.
90+
let jwt = AuthToken::new(DecodedAuthSubject::generate())
91+
.iat(chrono::Utc::now() - chrono::Duration::hours(2))
92+
.ttl(Duration::from_secs(3600))
93+
.as_jwt(&keypair)
94+
.unwrap();
95+
assert!(matches!(
96+
Jwt(jwt.into()).decode(&aud),
97+
Err(JwtVerificationError::Expired)
98+
));
99+
100+
// Expiration leeway, valid.
101+
let jwt = AuthToken::new(DecodedAuthSubject::generate())
102+
.iat(chrono::Utc::now() - chrono::Duration::seconds(3600 + JWT_VALIDATION_TIME_LEEWAY_SECS))
103+
.ttl(Duration::from_secs(3600))
104+
.as_jwt(&keypair)
105+
.unwrap();
106+
assert!(matches!(Jwt(jwt.into()).decode(&aud), Ok(_)));
107+
108+
// Expiration leeway, invalid.
109+
let jwt = AuthToken::new(DecodedAuthSubject::generate())
110+
.iat(
111+
chrono::Utc::now()
112+
- chrono::Duration::seconds(3600 + JWT_VALIDATION_TIME_LEEWAY_SECS + 1),
113+
)
114+
.ttl(Duration::from_secs(3600))
115+
.as_jwt(&keypair)
116+
.unwrap();
117+
assert!(matches!(
118+
Jwt(jwt.into()).decode(&aud),
119+
Err(JwtVerificationError::Expired)
120+
));
121+
122+
// Invalid aud.
123+
let jwt = AuthToken::new(DecodedAuthSubject::generate())
124+
.aud("wss://not.relay.walletconnect.com")
125+
.as_jwt(&keypair)
126+
.unwrap();
127+
assert!(matches!(
128+
Jwt(jwt.into()).decode(&aud),
129+
Err(JwtVerificationError::InvalidAudience)
130+
));
131+
}

0 commit comments

Comments
 (0)