tana_auth/
lib.rs

1//! Tana Authentication Library
2//!
3//! JWT creation and validation using Ed25519 signatures.
4//! Supports both native Rust and WebAssembly compilation.
5//!
6//! ## Key Features
7//!
8//! - User-signed JWTs (not server-signed)
9//! - Ed25519 signature verification
10//! - Compatible with existing Tana key format (ed25519_ prefix)
11//! - WASM support for TypeScript/Bun usage
12//!
13//! ## Usage
14//!
15//! ```rust
16//! use tana_auth::{create_jwt, verify_jwt};
17//!
18//! // Create JWT signed by user's private key
19//! let jwt = create_jwt(
20//!     "ed25519_abc123...",  // private key
21//!     "@alice",              // username
22//!     90                     // days until expiration
23//! ).unwrap();
24//!
25//! // Verify JWT against user's public key
26//! let claims = verify_jwt(&jwt, "ed25519_def456...").unwrap();
27//! ```
28
29use 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/// JWT claims for Tana authentication
36#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct JwtClaims {
38    /// Subject (username)
39    pub sub: String,
40    /// Network identifier (e.g., 'testnet.tana.network', 'mainnet.tana.network')
41    pub net: String,
42    /// Issued at (Unix timestamp)
43    pub iat: i64,
44    /// Expiration (Unix timestamp)
45    pub exp: i64,
46    /// Issuer (always self-signed)
47    pub iss: String,
48}
49
50/// JWT validation result
51#[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/// Create a JWT signed with user's Ed25519 private key
62///
63/// # Arguments
64///
65/// * `private_key_hex` - Private key in hex format (with or without 'ed25519_' prefix)
66/// * `username` - Username to encode in JWT (subject claim)
67/// * `network` - Network identifier (e.g., 'testnet.tana.network', 'mainnet.tana.network')
68/// * `expiry_days` - Number of days until JWT expires (typically 90)
69///
70/// # Returns
71///
72/// JWT string in format: `{header}.{payload}.{signature}`
73///
74/// # Example
75///
76/// ```rust
77/// let jwt = tana_auth::create_jwt(
78///     "ed25519_a1b2c3...",
79///     "@alice",
80///     "testnet.tana.network",
81///     90
82/// ).unwrap();
83/// ```
84#[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/// Verify a JWT signature against user's Ed25519 public key
96///
97/// # Arguments
98///
99/// * `jwt` - JWT string to verify
100/// * `public_key_hex` - Public key in hex format (with or without 'ed25519_' prefix)
101///
102/// # Returns
103///
104/// JwtValidation object with validation result and claims
105///
106/// # Example
107///
108/// ```rust
109/// let result = tana_auth::verify_jwt(&jwt, "ed25519_d4e5f6...").unwrap();
110/// if result.valid {
111///     println!("JWT valid for user: {}", result.username.unwrap());
112/// }
113/// ```
114#[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
120/// Internal implementation of JWT creation (also used for native Rust)
121pub fn create_jwt_impl(
122    private_key_hex: &str,
123    username: &str,
124    network: &str,
125    expiry_days: u32,
126) -> Result<String, String> {
127    // Remove 'ed25519_' prefix if present
128    let clean_key = if private_key_hex.starts_with("ed25519_") {
129        &private_key_hex[8..]
130    } else {
131        private_key_hex
132    };
133
134    // Decode private key
135    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    // Create JWT claims
145    let now = current_timestamp();
146    let expiry = now + (expiry_days as i64 * 86400); // days to seconds
147
148    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    // Create JWT header (always Ed25519)
157    let header = serde_json::json!({
158        "alg": "EdDSA",
159        "typ": "JWT"
160    });
161
162    // Encode header and payload
163    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    // Create signature input: {header}.{payload}
167    let signature_input = format!("{}.{}", header_b64, payload_b64);
168
169    // Hash the input with SHA-256 (same as transaction signing)
170    let mut hasher = Sha256::new();
171    hasher.update(signature_input.as_bytes());
172    let message_hash = hasher.finalize();
173
174    // Sign with Ed25519
175    let signature = signing_key.sign(&message_hash);
176
177    // Encode signature
178    let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
179
180    // Return complete JWT
181    Ok(format!("{}.{}.{}", header_b64, payload_b64, signature_b64))
182}
183
184/// Internal implementation of JWT verification (also used for native Rust)
185pub fn verify_jwt_impl(jwt: &str, public_key_hex: &str) -> JwtValidation {
186    // Split JWT into parts
187    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    // Decode and parse payload
202    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    // Check expiration
231    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    // Decode signature
244    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    // Decode public key
273    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    // Recreate signature input
308    let signature_input = format!("{}.{}", header_b64, payload_b64);
309
310    // Hash with SHA-256
311    let mut hasher = Sha256::new();
312    hasher.update(signature_input.as_bytes());
313    let message_hash = hasher.finalize();
314
315    // Verify signature
316    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
336/// Get current Unix timestamp in seconds
337fn 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
351/// Native Rust API (not exposed to WASM)
352impl JwtClaims {
353    /// Create claims for a user
354    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    /// Check if claims are expired
368    pub fn is_expired(&self) -> bool {
369        current_timestamp() > self.exp
370    }
371}
372
373/// Helper module for hex encoding/decoding
374mod 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        // JWT should have 3 parts
401        assert_eq!(jwt.split('.').count(), 3);
402
403        // Should be able to decode payload
404        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        // Manually set to expired
420        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        // Invalid hex
430        assert!(hex::decode("xyz").is_err());
431
432        // Odd length
433        assert!(hex::decode("a1b").is_err());
434    }
435}