use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use hmac::{Hmac, KeyInit, Mac}; use pbkdf2::pbkdf2_hmac; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use worker::{ js_sys::{Function, Reflect, Uint8Array}, wasm_bindgen::{JsCast, JsValue}, Date, Env, Request, }; pub const ADMIN_SESSION_SECONDS: i64 = 7 * 24 * 60 * 60; const TURNSTILE_PROOF_SECONDS: i64 = 60 * 60; const THEME_PREVIEW_SECONDS: i64 = 10 * 60; const SERVER_PASSWORD_ROUNDS: u32 = 10_000; const JWT_HEADER: &str = r#"{"alg":"HS256","typ":"JWT"}"#; type HmacSha256 = Hmac; #[derive(Debug, Deserialize, Serialize)] struct AdminClaims { sub: String, iat: i64, exp: i64, } pub fn sha256_hex(value: &str) -> String { let digest = Sha256::digest(value.as_bytes()); digest.iter().map(|byte| format!("{byte:02x}")).collect() } fn secure_eq(left: &str, right: &str) -> bool { let left = Sha256::digest(left.as_bytes()); let right = Sha256::digest(right.as_bytes()); left.iter() .zip(right.iter()) .fold(0_u8, |diff, (a, b)| diff | (a ^ b)) == 0 } fn admin_secret(env: &Env) -> Option { env.secret("ADMIN_PASSWORD").ok().map(|v| v.to_string()) } fn password_digest(password_derived: &str, salt: &str, rounds: u32) -> String { let mut digest = [0_u8; 32]; pbkdf2_hmac::( password_derived.as_bytes(), salt.as_bytes(), rounds, &mut digest, ); digest.iter().map(|byte| format!("{byte:02x}")).collect() } pub fn hash_password(password_derived: &str, salt: &str) -> String { format!( "pbkdf2_sha256_client_v1${SERVER_PASSWORD_ROUNDS}${salt}${}", password_digest(password_derived, salt, SERVER_PASSWORD_ROUNDS) ) } pub fn random_salt() -> Option { let global = worker::js_sys::global(); let crypto = Reflect::get(&global, &JsValue::from_str("crypto")).ok()?; let get_random_values = Reflect::get(&crypto, &JsValue::from_str("getRandomValues")) .ok()? .dyn_into::() .ok()?; let salt = Uint8Array::new_with_length(16); get_random_values.call1(&crypto, &salt).ok()?; Some( salt.to_vec() .iter() .map(|byte| format!("{byte:02x}")) .collect(), ) } fn verify_password_hash(password_derived: &str, encoded: &str) -> bool { let mut parts = encoded.split('$'); let Some(algorithm) = parts.next() else { return false; }; let Some(rounds) = parts.next().and_then(|value| value.parse::().ok()) else { return false; }; let Some(salt) = parts.next() else { return false; }; let Some(expected) = parts.next() else { return false; }; if algorithm != "pbkdf2_sha256_client_v1" || rounds != SERVER_PASSWORD_ROUNDS || salt.len() < 32 || parts.next().is_some() { return false; } secure_eq(&password_digest(password_derived, salt, rounds), expected) } pub fn verify_credentials( env: &Env, configured_username: &str, password_hash: &str, username: &str, password: &str, password_derived: &str, ) -> bool { let expected_username = configured_username.trim(); let username_valid = !expected_username.is_empty() && secure_eq(expected_username, username.trim()); let password_valid = if password_hash.is_empty() { admin_secret(env) .map(|expected| secure_eq(&expected, password)) .unwrap_or(false) } else { verify_password_hash(password_derived, password_hash) }; username_valid && password_valid } fn signing_secret(env: &Env, password_hash: &str) -> Option { if !password_hash.is_empty() { return Some(password_hash.to_string()); } admin_secret(env) } fn create_admin_jwt_with_secret(secret: &str, issued_at: i64) -> Option { let header = URL_SAFE_NO_PAD.encode(JWT_HEADER); let claims = URL_SAFE_NO_PAD.encode( serde_json::to_vec(&AdminClaims { sub: "admin".to_string(), iat: issued_at, exp: issued_at + ADMIN_SESSION_SECONDS, }) .ok()?, ); let unsigned = format!("{header}.{claims}"); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?; mac.update(unsigned.as_bytes()); let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); Some(format!("{unsigned}.{signature}")) } fn verify_admin_jwt(token: &str, secret: &str, now: i64) -> bool { let mut parts = token.split('.'); let (Some(header), Some(claims), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { return false; }; if header != URL_SAFE_NO_PAD.encode(JWT_HEADER) { return false; } let Ok(signature) = URL_SAFE_NO_PAD.decode(signature) else { return false; }; let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else { return false; }; mac.update(format!("{header}.{claims}").as_bytes()); if mac.verify_slice(&signature).is_err() { return false; } let Ok(payload) = URL_SAFE_NO_PAD.decode(claims) else { return false; }; let Ok(claims) = serde_json::from_slice::(&payload) else { return false; }; claims.sub == "admin" && claims.iat <= now + 60 && claims.exp >= now && claims.exp == claims.iat + ADMIN_SESSION_SECONDS } pub fn create_admin_jwt(env: &Env, password_hash: &str) -> Option { let secret = signing_secret(env, password_hash)?; create_admin_jwt_with_secret(&secret, Date::now().as_millis() as i64 / 1000) } pub fn is_admin(req: &Request, env: &Env, password_hash: &str) -> bool { let Some(secret) = signing_secret(env, password_hash) else { return false; }; let now = Date::now().as_millis() as i64 / 1000; let bearer_valid = req .headers() .get("Authorization") .ok() .flatten() .is_some_and(|header| { header .strip_prefix("Bearer ") .is_some_and(|token| verify_admin_jwt(token, &secret, now)) }); if bearer_valid { return true; } req.headers() .get("Cookie") .ok() .flatten() .is_some_and(|cookies| { cookies .split(';') .find_map(|entry| { let (name, value) = entry.trim().split_once('=')?; (name == "nodeflare_admin").then_some(value) }) .is_some_and(|token| verify_admin_jwt(token, &secret, now)) }) } pub fn create_turnstile_proof(env: &Env, password_hash: &str) -> Option { let secret = signing_secret(env, password_hash)?; let expires = Date::now().as_millis() as i64 / 1000 + TURNSTILE_PROOF_SECONDS; let signature = sha256_hex(&format!("nodeflare-turnstile:{expires}:{secret}")); Some(format!("{expires}.{signature}")) } pub fn verify_turnstile_proof(value: &str, env: &Env, password_hash: &str) -> bool { let Some(secret) = signing_secret(env, password_hash) else { return false; }; let Some((expires_raw, signature)) = value.split_once('.') else { return false; }; let Ok(expires) = expires_raw.parse::() else { return false; }; let current = Date::now().as_millis() as i64 / 1000; if expires < current || expires > current + TURNSTILE_PROOF_SECONDS + 60 { return false; } let expected = sha256_hex(&format!("nodeflare-turnstile:{expires}:{secret}")); secure_eq(&expected, signature) } pub fn create_theme_preview_proof( env: &Env, password_hash: &str, theme_id: &str, ) -> Option { let secret = signing_secret(env, password_hash)?; let expires = Date::now().as_millis() as i64 / 1000 + THEME_PREVIEW_SECONDS; let payload = format!("{theme_id}.{expires}"); let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?; mac.update(format!("nodeflare-theme-preview:{payload}").as_bytes()); let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); Some(format!("{payload}.{signature}")) } pub fn verify_theme_preview_proof(value: &str, env: &Env, password_hash: &str) -> Option { let secret = signing_secret(env, password_hash)?; let mut parts = value.split('.'); let (Some(theme_id), Some(expires), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { return None; }; if theme_id.is_empty() || theme_id.len() > 80 || theme_id.contains('/') { return None; } let expires = expires.parse::().ok()?; let current = Date::now().as_millis() as i64 / 1000; if expires < current || expires > current + THEME_PREVIEW_SECONDS + 60 { return None; } let signature = URL_SAFE_NO_PAD.decode(signature).ok()?; let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?; mac.update(format!("nodeflare-theme-preview:{theme_id}.{expires}").as_bytes()); mac.verify_slice(&signature).ok()?; Some(theme_id.to_string()) } pub fn bearer_token(req: &Request) -> Option { req.headers() .get("Authorization") .ok() .flatten() .and_then(|value| value.strip_prefix("Bearer ").map(ToOwned::to_owned)) } #[cfg(test)] mod tests { use super::{ create_admin_jwt_with_secret, hash_password, verify_admin_jwt, verify_password_hash, ADMIN_SESSION_SECONDS, }; #[test] fn hashes_passwords_with_pbkdf2() { let encoded = hash_password( "correct horse battery staple", "0123456789abcdef0123456789abcdef", ); assert!(encoded.starts_with("pbkdf2_sha256_client_v1$10000$")); assert!(verify_password_hash( "correct horse battery staple", &encoded )); assert!(!verify_password_hash("wrong", &encoded)); assert!(!verify_password_hash( "correct horse battery staple", "sha256$0123456789abcdef0123456789abcdef$invalid" )); } #[test] fn signs_and_verifies_admin_jwt() { let issued_at = 1_800_000_000; let token = create_admin_jwt_with_secret("d1-password-hash", issued_at).expect("jwt"); assert_eq!(token.split('.').count(), 3); assert!(verify_admin_jwt(&token, "d1-password-hash", issued_at + 1)); assert!(verify_admin_jwt( &token, "d1-password-hash", issued_at + ADMIN_SESSION_SECONDS )); assert!(!verify_admin_jwt( &token, "d1-password-hash", issued_at + ADMIN_SESSION_SECONDS + 1 )); assert!(!verify_admin_jwt(&token, "different-secret", issued_at)); let tampered = format!("{}x", token); assert!(!verify_admin_jwt(&tampered, "d1-password-hash", issued_at)); } }