1use wasm_bindgen::prelude::*;
30use serde::{Deserialize, Serialize};
31use ed25519_dalek::{Signer, Verifier, SigningKey, VerifyingKey, Signature};
32use sha2::{Sha256, Digest};
33use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct JwtClaims {
38 pub sub: String,
40 pub net: String,
42 pub iat: i64,
44 pub exp: i64,
46 pub iss: String,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct JwtValidation {
53 pub valid: bool,
54 pub username: Option<String>,
55 pub network: Option<String>,
56 pub issued_at: Option<i64>,
57 pub expires_at: Option<i64>,
58 pub error: Option<String>,
59}
60
61#[wasm_bindgen]
85pub fn create_jwt(
86 private_key_hex: &str,
87 username: &str,
88 network: &str,
89 expiry_days: u32,
90) -> Result<String, JsValue> {
91 create_jwt_impl(private_key_hex, username, network, expiry_days)
92 .map_err(|e| JsValue::from_str(&e))
93}
94
95#[wasm_bindgen]
115pub fn verify_jwt(jwt: &str, public_key_hex: &str) -> Result<JsValue, JsValue> {
116 let result = verify_jwt_impl(jwt, public_key_hex);
117 serde_wasm_bindgen::to_value(&result).map_err(|e| JsValue::from_str(&e.to_string()))
118}
119
120pub fn create_jwt_impl(
122 private_key_hex: &str,
123 username: &str,
124 network: &str,
125 expiry_days: u32,
126) -> Result<String, String> {
127 let clean_key = if private_key_hex.starts_with("ed25519_") {
129 &private_key_hex[8..]
130 } else {
131 private_key_hex
132 };
133
134 let key_bytes = hex::decode(clean_key)
136 .map_err(|_| "Invalid private key hex format".to_string())?;
137
138 if key_bytes.len() != 32 {
139 return Err("Invalid private key length (expected 32 bytes)".to_string());
140 }
141
142 let signing_key = SigningKey::from_bytes(&key_bytes.try_into().unwrap());
143
144 let now = current_timestamp();
146 let expiry = now + (expiry_days as i64 * 86400); let claims = JwtClaims {
149 sub: username.to_string(),
150 net: network.to_string(),
151 iat: now,
152 exp: expiry,
153 iss: "self".to_string(),
154 };
155
156 let header = serde_json::json!({
158 "alg": "EdDSA",
159 "typ": "JWT"
160 });
161
162 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
164 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
165
166 let signature_input = format!("{}.{}", header_b64, payload_b64);
168
169 let mut hasher = Sha256::new();
171 hasher.update(signature_input.as_bytes());
172 let message_hash = hasher.finalize();
173
174 let signature = signing_key.sign(&message_hash);
176
177 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
179
180 Ok(format!("{}.{}.{}", header_b64, payload_b64, signature_b64))
182}
183
184pub fn verify_jwt_impl(jwt: &str, public_key_hex: &str) -> JwtValidation {
186 let parts: Vec<&str> = jwt.split('.').collect();
188 if parts.len() != 3 {
189 return JwtValidation {
190 valid: false,
191 username: None,
192 network: None,
193 issued_at: None,
194 expires_at: None,
195 error: Some("Invalid JWT format (expected 3 parts)".to_string()),
196 };
197 }
198
199 let (header_b64, payload_b64, signature_b64) = (parts[0], parts[1], parts[2]);
200
201 let payload_bytes = match URL_SAFE_NO_PAD.decode(payload_b64) {
203 Ok(b) => b,
204 Err(_) => {
205 return JwtValidation {
206 valid: false,
207 username: None,
208 network: None,
209 issued_at: None,
210 expires_at: None,
211 error: Some("Invalid base64 in payload".to_string()),
212 };
213 }
214 };
215
216 let claims: JwtClaims = match serde_json::from_slice(&payload_bytes) {
217 Ok(c) => c,
218 Err(_) => {
219 return JwtValidation {
220 valid: false,
221 username: None,
222 network: None,
223 issued_at: None,
224 expires_at: None,
225 error: Some("Invalid JSON in payload".to_string()),
226 };
227 }
228 };
229
230 let now = current_timestamp();
232 if now > claims.exp {
233 return JwtValidation {
234 valid: false,
235 username: Some(claims.sub),
236 network: Some(claims.net),
237 issued_at: Some(claims.iat),
238 expires_at: Some(claims.exp),
239 error: Some("JWT expired".to_string()),
240 };
241 }
242
243 let signature_bytes = match URL_SAFE_NO_PAD.decode(signature_b64) {
245 Ok(b) => b,
246 Err(_) => {
247 return JwtValidation {
248 valid: false,
249 username: Some(claims.sub),
250 network: Some(claims.net),
251 issued_at: Some(claims.iat),
252 expires_at: Some(claims.exp),
253 error: Some("Invalid base64 in signature".to_string()),
254 };
255 }
256 };
257
258 let signature = match Signature::from_slice(&signature_bytes) {
259 Ok(s) => s,
260 Err(_) => {
261 return JwtValidation {
262 valid: false,
263 username: Some(claims.sub),
264 network: Some(claims.net),
265 issued_at: Some(claims.iat),
266 expires_at: Some(claims.exp),
267 error: Some("Invalid signature format".to_string()),
268 };
269 }
270 };
271
272 let clean_key = if public_key_hex.starts_with("ed25519_") {
274 &public_key_hex[8..]
275 } else {
276 public_key_hex
277 };
278
279 let key_bytes = match hex::decode(clean_key) {
280 Ok(b) => b,
281 Err(_) => {
282 return JwtValidation {
283 valid: false,
284 username: Some(claims.sub),
285 network: Some(claims.net),
286 issued_at: Some(claims.iat),
287 expires_at: Some(claims.exp),
288 error: Some("Invalid public key hex format".to_string()),
289 };
290 }
291 };
292
293 let verifying_key = match VerifyingKey::from_bytes(&key_bytes.try_into().unwrap()) {
294 Ok(k) => k,
295 Err(_) => {
296 return JwtValidation {
297 valid: false,
298 username: Some(claims.sub),
299 network: Some(claims.net),
300 issued_at: Some(claims.iat),
301 expires_at: Some(claims.exp),
302 error: Some("Invalid public key".to_string()),
303 };
304 }
305 };
306
307 let signature_input = format!("{}.{}", header_b64, payload_b64);
309
310 let mut hasher = Sha256::new();
312 hasher.update(signature_input.as_bytes());
313 let message_hash = hasher.finalize();
314
315 match verifying_key.verify(&message_hash, &signature) {
317 Ok(_) => JwtValidation {
318 valid: true,
319 username: Some(claims.sub),
320 network: Some(claims.net),
321 issued_at: Some(claims.iat),
322 expires_at: Some(claims.exp),
323 error: None,
324 },
325 Err(_) => JwtValidation {
326 valid: false,
327 username: Some(claims.sub),
328 network: Some(claims.net),
329 issued_at: Some(claims.iat),
330 expires_at: Some(claims.exp),
331 error: Some("Signature verification failed".to_string()),
332 },
333 }
334}
335
336fn current_timestamp() -> i64 {
338 #[cfg(target_arch = "wasm32")]
339 {
340 (js_sys::Date::now() / 1000.0) as i64
341 }
342 #[cfg(not(target_arch = "wasm32"))]
343 {
344 std::time::SystemTime::now()
345 .duration_since(std::time::UNIX_EPOCH)
346 .unwrap()
347 .as_secs() as i64
348 }
349}
350
351impl JwtClaims {
353 pub fn new(username: &str, network: &str, expiry_days: u32) -> Self {
355 let now = current_timestamp();
356 let expiry = now + (expiry_days as i64 * 86400);
357
358 Self {
359 sub: username.to_string(),
360 net: network.to_string(),
361 iat: now,
362 exp: expiry,
363 iss: "self".to_string(),
364 }
365 }
366
367 pub fn is_expired(&self) -> bool {
369 current_timestamp() > self.exp
370 }
371}
372
373mod hex {
375 pub fn decode(s: &str) -> Result<Vec<u8>, ()> {
376 if s.len() % 2 != 0 {
377 return Err(());
378 }
379
380 (0..s.len())
381 .step_by(2)
382 .map(|i| {
383 u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ())
384 })
385 .collect()
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 const TEST_PRIVATE_KEY: &str = "ed25519_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2";
394 const TEST_PUBLIC_KEY: &str = "ed25519_f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2d3c4b5a6f1e2";
395
396 #[test]
397 fn test_jwt_creation() {
398 let jwt = create_jwt_impl(TEST_PRIVATE_KEY, "@alice", "testnet.tana.network", 90).unwrap();
399
400 assert_eq!(jwt.split('.').count(), 3);
402
403 let parts: Vec<&str> = jwt.split('.').collect();
405 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
406 let claims: JwtClaims = serde_json::from_slice(&payload_bytes).unwrap();
407
408 assert_eq!(claims.sub, "@alice");
409 assert_eq!(claims.net, "testnet.tana.network");
410 assert_eq!(claims.iss, "self");
411 assert!(claims.exp > claims.iat);
412 }
413
414 #[test]
415 fn test_claims_expiration() {
416 let mut claims = JwtClaims::new("@alice", "testnet.tana.network", 90);
417 assert!(!claims.is_expired());
418
419 claims.exp = current_timestamp() - 1;
421 assert!(claims.is_expired());
422 }
423
424 #[test]
425 fn test_hex_decode() {
426 let result = hex::decode("a1b2c3").unwrap();
427 assert_eq!(result, vec![0xa1, 0xb2, 0xc3]);
428
429 assert!(hex::decode("xyz").is_err());
431
432 assert!(hex::decode("a1b").is_err());
434 }
435}