Skip to main content

opensession_api_types/
crypto.rs

1//! Cryptographic helpers for authentication.
2//!
3//! - PBKDF2-SHA256 password hashing (600k iterations)
4//! - HMAC-SHA256 JWT signing/verification
5//!
6//! Uses pure Rust crates (wasm-compatible, no WebCrypto interop needed).
7
8use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
9use hmac::{Hmac, Mac};
10use pbkdf2::pbkdf2_hmac;
11use sha2::Sha256;
12
13use crate::ServiceError;
14
15const PBKDF2_ITERATIONS: u32 = 600_000;
16const SALT_LEN: usize = 16;
17const HASH_LEN: usize = 32;
18
19// ── Password hashing ────────────────────────────────────────────────────────
20
21/// Hash a password with PBKDF2-SHA256. Returns `(hash_hex, salt_hex)`.
22pub fn hash_password(password: &str) -> (String, String) {
23    let mut salt = [0u8; SALT_LEN];
24    getrandom::getrandom(&mut salt).expect("getrandom failed");
25
26    let mut hash = [0u8; HASH_LEN];
27    pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, PBKDF2_ITERATIONS, &mut hash);
28
29    (hex::encode(hash), hex::encode(salt))
30}
31
32/// Verify a password against a stored hash and salt (both hex-encoded).
33pub fn verify_password(password: &str, hash_hex: &str, salt_hex: &str) -> bool {
34    let Ok(salt) = hex::decode(salt_hex) else {
35        return false;
36    };
37    let Ok(expected) = hex::decode(hash_hex) else {
38        return false;
39    };
40
41    let mut hash = [0u8; HASH_LEN];
42    pbkdf2_hmac::<Sha256>(password.as_bytes(), &salt, PBKDF2_ITERATIONS, &mut hash);
43
44    // Constant-time comparison
45    hash.len() == expected.len() && hash.iter().zip(expected.iter()).all(|(a, b)| a == b)
46}
47
48// ── JWT (HMAC-SHA256) ───────────────────────────────────────────────────────
49
50/// JWT header (always HS256).
51const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#;
52
53/// JWT expiry: 1 hour in seconds.
54pub const JWT_EXPIRY_SECS: u64 = 3600;
55
56/// Refresh token expiry: 7 days in seconds.
57pub const REFRESH_EXPIRY_SECS: u64 = 7 * 24 * 3600;
58
59/// Sign a JWT for the given user. Returns the encoded JWT string.
60pub fn sign_jwt(user_id: &str, secret: &str, now_unix: u64) -> String {
61    let header_b64 = URL_SAFE_NO_PAD.encode(JWT_HEADER.as_bytes());
62
63    let payload = format!(
64        r#"{{"sub":"{}","iat":{},"exp":{}}}"#,
65        user_id,
66        now_unix,
67        now_unix + JWT_EXPIRY_SECS,
68    );
69    let payload_b64 = URL_SAFE_NO_PAD.encode(payload.as_bytes());
70
71    let signing_input = format!("{header_b64}.{payload_b64}");
72    let signature = hmac_sha256(secret.as_bytes(), signing_input.as_bytes());
73    let sig_b64 = URL_SAFE_NO_PAD.encode(signature);
74
75    format!("{signing_input}.{sig_b64}")
76}
77
78/// Verify a JWT and return the `sub` (user_id) if valid.
79pub fn verify_jwt(token: &str, secret: &str, now_unix: u64) -> Result<String, ServiceError> {
80    let parts: Vec<&str> = token.split('.').collect();
81    if parts.len() != 3 {
82        return Err(ServiceError::Unauthorized("invalid JWT format".into()));
83    }
84
85    // Verify signature
86    let signing_input = format!("{}.{}", parts[0], parts[1]);
87    let expected_sig = hmac_sha256(secret.as_bytes(), signing_input.as_bytes());
88    let actual_sig = URL_SAFE_NO_PAD
89        .decode(parts[2])
90        .map_err(|_| ServiceError::Unauthorized("invalid JWT signature encoding".into()))?;
91
92    if expected_sig.len() != actual_sig.len()
93        || !expected_sig
94            .iter()
95            .zip(actual_sig.iter())
96            .all(|(a, b)| a == b)
97    {
98        return Err(ServiceError::Unauthorized("invalid JWT signature".into()));
99    }
100
101    // Decode payload
102    let payload_bytes = URL_SAFE_NO_PAD
103        .decode(parts[1])
104        .map_err(|_| ServiceError::Unauthorized("invalid JWT payload encoding".into()))?;
105    let payload: serde_json::Value = serde_json::from_slice(&payload_bytes)
106        .map_err(|_| ServiceError::Unauthorized("invalid JWT payload".into()))?;
107
108    // Check expiry
109    let exp = payload["exp"]
110        .as_u64()
111        .ok_or_else(|| ServiceError::Unauthorized("missing exp claim".into()))?;
112    if now_unix > exp {
113        return Err(ServiceError::Unauthorized("JWT expired".into()));
114    }
115
116    // Extract sub
117    let sub = payload["sub"]
118        .as_str()
119        .ok_or_else(|| ServiceError::Unauthorized("missing sub claim".into()))?
120        .to_string();
121
122    Ok(sub)
123}
124
125/// Generate a secure random token (for refresh tokens). Returns hex-encoded.
126pub fn generate_token() -> String {
127    let mut bytes = [0u8; 32];
128    getrandom::getrandom(&mut bytes).expect("getrandom failed");
129    hex::encode(bytes)
130}
131
132/// Hash a token with SHA-256 for storage. Returns hex-encoded.
133pub fn hash_token(token: &str) -> String {
134    use sha2::Digest;
135    let hash = sha2::Sha256::digest(token.as_bytes());
136    hex::encode(hash)
137}
138
139// ── Internal ────────────────────────────────────────────────────────────────
140
141fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
142    let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC can take key of any size");
143    mac.update(data);
144    mac.finalize().into_bytes().to_vec()
145}