275 lines
9.2 KiB
Rust
275 lines
9.2 KiB
Rust
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
|
use constant_time_eq::constant_time_eq;
|
|
use js_sys::Uint8Array;
|
|
use pbkdf2::pbkdf2_hmac;
|
|
use sha2::Sha256;
|
|
use wasm_bindgen::{JsCast, JsValue};
|
|
use wasm_bindgen_futures::JsFuture;
|
|
use web_sys::{Crypto, CryptoKey, SubtleCrypto};
|
|
use worker::js_sys;
|
|
|
|
use crate::error::AppError;
|
|
|
|
/// Minimum PBKDF2 iterations for server-side password hashing (new global minimum).
|
|
///
|
|
/// NOTE: Cloudflare Workers native WebCrypto PBKDF2 currently rejects iteration counts > 100_000.
|
|
/// We therefore run PBKDF2 in pure Rust/WASM so we can safely use higher iteration counts.
|
|
///
|
|
/// This is CPU-expensive and is expected to be executed inside a Durable Object for Free plan deployments.
|
|
pub const MIN_SERVER_PBKDF2_ITERATIONS: u32 = 600_000;
|
|
|
|
/// Salt length in bytes for server-side password hashing.
|
|
pub const PASSWORD_SALT_LENGTH: usize = 64;
|
|
/// Derived key length in bits
|
|
const KEY_LENGTH_BITS: u32 = 256;
|
|
|
|
/// Gets the Crypto interface from the global scope.
|
|
/// Works in Cloudflare Workers by using js_sys::Reflect instead of WorkerGlobalScope.
|
|
fn get_crypto() -> Result<Crypto, AppError> {
|
|
let global = js_sys::global();
|
|
let crypto_value = js_sys::Reflect::get(&global, &JsValue::from_str("crypto"))
|
|
.map_err(|e| AppError::Crypto(format!("Failed to get crypto property: {:?}", e)))?;
|
|
|
|
crypto_value
|
|
.dyn_into::<Crypto>()
|
|
.map_err(|_| AppError::Crypto("Failed to cast to Crypto".to_string()))
|
|
}
|
|
|
|
/// Gets the SubtleCrypto interface from the global scope.
|
|
fn subtle_crypto() -> Result<SubtleCrypto, AppError> {
|
|
Ok(get_crypto()?.subtle())
|
|
}
|
|
|
|
/// Derives a key using PBKDF2-HMAC-SHA256 (pure Rust).
|
|
pub fn pbkdf2_sha256(
|
|
password: &[u8],
|
|
salt: &[u8],
|
|
iterations: u32,
|
|
key_length_bits: u32,
|
|
) -> Result<Vec<u8>, AppError> {
|
|
if !key_length_bits.is_multiple_of(8) {
|
|
return Err(AppError::Crypto(format!(
|
|
"PBKDF2 key length must be a multiple of 8 bits (got {})",
|
|
key_length_bits
|
|
)));
|
|
}
|
|
|
|
let dk_len = (key_length_bits / 8) as usize;
|
|
let mut out = vec![0u8; dk_len];
|
|
pbkdf2_hmac::<Sha256>(password, salt, iterations, &mut out);
|
|
Ok(out)
|
|
}
|
|
|
|
/// Generates a cryptographically secure random salt.
|
|
pub fn generate_salt() -> Result<String, AppError> {
|
|
let crypto = get_crypto()?;
|
|
let salt = Uint8Array::new_with_length(PASSWORD_SALT_LENGTH as u32);
|
|
crypto
|
|
.get_random_values_with_array_buffer_view(&salt)
|
|
.map_err(|e| AppError::Crypto(format!("Failed to generate random salt: {:?}", e)))?;
|
|
|
|
Ok(BASE64.encode(salt.to_vec()))
|
|
}
|
|
|
|
/// Hashes the client-provided master password hash with server-side PBKDF2.
|
|
/// This adds an additional layer of security to the stored password hash.
|
|
pub async fn hash_password_for_storage(
|
|
client_password_hash: &str,
|
|
salt: &str,
|
|
iterations: u32,
|
|
) -> Result<String, AppError> {
|
|
let salt_bytes = BASE64
|
|
.decode(salt)
|
|
.map_err(|e| AppError::Crypto(format!("Failed to decode salt: {:?}", e)))?;
|
|
|
|
let derived = pbkdf2_sha256(
|
|
client_password_hash.as_bytes(),
|
|
&salt_bytes,
|
|
iterations,
|
|
KEY_LENGTH_BITS,
|
|
)?;
|
|
|
|
Ok(BASE64.encode(derived))
|
|
}
|
|
|
|
/// Verifies a password against a stored hash.
|
|
/// Returns true if the password matches.
|
|
pub async fn verify_password(
|
|
client_password_hash: &str,
|
|
stored_hash: &str,
|
|
salt: &str,
|
|
iterations: u32,
|
|
) -> Result<bool, AppError> {
|
|
let computed_hash = hash_password_for_storage(client_password_hash, salt, iterations).await?;
|
|
Ok(constant_time_eq(
|
|
computed_hash.as_bytes(),
|
|
stored_hash.as_bytes(),
|
|
))
|
|
}
|
|
|
|
// ============================================================================
|
|
// TOTP Implementation using Web Crypto API
|
|
// ============================================================================
|
|
|
|
/// Decodes a Base32 encoded string into bytes.
|
|
/// Handles both uppercase and lowercase input.
|
|
pub fn base32_decode(input: &str) -> Result<Vec<u8>, AppError> {
|
|
base32::decode(base32::Alphabet::Rfc4648 { padding: true }, input)
|
|
.or_else(|| base32::decode(base32::Alphabet::Rfc4648 { padding: false }, input))
|
|
.ok_or_else(|| AppError::Crypto("Invalid Base32 input".to_string()))
|
|
}
|
|
|
|
/// Encodes bytes into a Base32 string (RFC 4648, uppercase).
|
|
pub fn base32_encode(data: &[u8]) -> String {
|
|
base32::encode(base32::Alphabet::Rfc4648 { padding: true }, data)
|
|
}
|
|
|
|
/// Generates a random TOTP secret (20 bytes = 160 bits).
|
|
/// Returns the Base32 encoded secret.
|
|
pub fn generate_totp_secret() -> Result<String, AppError> {
|
|
let crypto = get_crypto()?;
|
|
let secret = Uint8Array::new_with_length(20);
|
|
crypto
|
|
.get_random_values_with_array_buffer_view(&secret)
|
|
.map_err(|e| AppError::Crypto(format!("Failed to generate TOTP secret: {:?}", e)))?;
|
|
|
|
Ok(base32_encode(&secret.to_vec()))
|
|
}
|
|
|
|
/// Computes HMAC-SHA1 using Web Crypto API.
|
|
async fn hmac_sha1(key: &[u8], data: &[u8]) -> Result<Vec<u8>, AppError> {
|
|
let subtle = subtle_crypto()?;
|
|
|
|
// Create algorithm object for HMAC with SHA-1
|
|
let algorithm = js_sys::Object::new();
|
|
js_sys::Reflect::set(
|
|
&algorithm,
|
|
&JsValue::from_str("name"),
|
|
&JsValue::from_str("HMAC"),
|
|
)
|
|
.map_err(|e| AppError::Crypto(format!("Failed to set algorithm name: {:?}", e)))?;
|
|
js_sys::Reflect::set(
|
|
&algorithm,
|
|
&JsValue::from_str("hash"),
|
|
&JsValue::from_str("SHA-1"),
|
|
)
|
|
.map_err(|e| AppError::Crypto(format!("Failed to set hash: {:?}", e)))?;
|
|
|
|
// Import the key
|
|
let key_array = Uint8Array::new_from_slice(key);
|
|
let key_usages = js_sys::Array::of1(&JsValue::from_str("sign"));
|
|
|
|
let crypto_key = JsFuture::from(
|
|
subtle
|
|
.import_key_with_object("raw", &key_array, &algorithm, false, &key_usages)
|
|
.map_err(|e| AppError::Crypto(format!("HMAC import_key failed: {:?}", e)))?,
|
|
)
|
|
.await
|
|
.map_err(|e| AppError::Crypto(format!("HMAC import_key await failed: {:?}", e)))?;
|
|
|
|
// Sign the data using sign_with_str_and_buffer_source
|
|
let data_array = Uint8Array::new_from_slice(data);
|
|
let signature = JsFuture::from(
|
|
subtle
|
|
.sign_with_str_and_buffer_source("HMAC", &CryptoKey::from(crypto_key), &data_array)
|
|
.map_err(|e| AppError::Crypto(format!("HMAC sign failed: {:?}", e)))?,
|
|
)
|
|
.await
|
|
.map_err(|e| AppError::Crypto(format!("HMAC sign await failed: {:?}", e)))?;
|
|
|
|
Ok(Uint8Array::new(&signature).to_vec())
|
|
}
|
|
|
|
/// Generates a TOTP code for the given secret and time.
|
|
///
|
|
/// # Arguments
|
|
/// * `secret` - Base32 encoded secret key
|
|
/// * `time_step` - Unix timestamp divided by 30 (or custom period)
|
|
///
|
|
/// # Returns
|
|
/// * 6-digit TOTP code as a string
|
|
pub async fn generate_totp(secret: &str, time_step: u64) -> Result<String, AppError> {
|
|
let decoded_secret = base32_decode(secret)?;
|
|
|
|
// Convert time_step to big-endian bytes
|
|
let counter = time_step.to_be_bytes();
|
|
|
|
// Compute HMAC-SHA1
|
|
let hmac = hmac_sha1(&decoded_secret, &counter).await?;
|
|
|
|
// Dynamic truncation (RFC 4226)
|
|
let offset = (hmac[19] & 0x0f) as usize;
|
|
let code = ((hmac[offset] & 0x7f) as u32) << 24
|
|
| (hmac[offset + 1] as u32) << 16
|
|
| (hmac[offset + 2] as u32) << 8
|
|
| (hmac[offset + 3] as u32);
|
|
|
|
// Get 6-digit code
|
|
let otp = code % 1_000_000;
|
|
Ok(format!("{:06}", otp))
|
|
}
|
|
|
|
/// Validates a TOTP code against a secret.
|
|
///
|
|
/// # Arguments
|
|
/// * `code` - The TOTP code to validate
|
|
/// * `secret` - Base32 encoded secret key
|
|
/// * `last_used` - The last used time step (for replay protection)
|
|
/// * `allow_drift` - Whether to allow 1 time step drift (±30 seconds)
|
|
///
|
|
/// # Returns
|
|
/// * `Ok(time_step)` if valid, with the time step that matched
|
|
/// * `Err` if invalid
|
|
pub async fn validate_totp(
|
|
code: &str,
|
|
secret: &str,
|
|
last_used: i64,
|
|
allow_drift: bool,
|
|
) -> Result<i64, AppError> {
|
|
// Validate code format
|
|
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_digit()) {
|
|
return Err(AppError::BadRequest("Invalid TOTP code format".to_string()));
|
|
}
|
|
|
|
let current_time = chrono::Utc::now().timestamp();
|
|
let current_step = current_time / 30;
|
|
|
|
// Check drift range
|
|
let steps: i64 = if allow_drift { 1 } else { 0 };
|
|
|
|
for step_offset in -steps..=steps {
|
|
let time_step = current_step + step_offset;
|
|
|
|
// Skip if this time step was already used (replay protection)
|
|
if time_step <= last_used {
|
|
continue;
|
|
}
|
|
|
|
let expected = generate_totp(secret, time_step as u64).await?;
|
|
|
|
if constant_time_eq(code.as_bytes(), expected.as_bytes()) {
|
|
return Ok(time_step);
|
|
}
|
|
}
|
|
|
|
Err(AppError::Unauthorized(format!(
|
|
"Invalid TOTP code. Server time: {}",
|
|
chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
|
|
)))
|
|
}
|
|
|
|
/// Generates a recovery code (20 characters, Base32 encoded).
|
|
pub fn generate_recovery_code() -> Result<String, AppError> {
|
|
let crypto = get_crypto()?;
|
|
let bytes = Uint8Array::new_with_length(20);
|
|
crypto
|
|
.get_random_values_with_array_buffer_view(&bytes)
|
|
.map_err(|e| AppError::Crypto(format!("Failed to generate recovery code: {:?}", e)))?;
|
|
|
|
Ok(base32_encode(&bytes.to_vec()))
|
|
}
|
|
|
|
/// Constant-time string comparison wrapper.
|
|
pub fn ct_eq(a: &str, b: &str) -> bool {
|
|
constant_time_eq(a.as_bytes(), b.as_bytes())
|
|
}
|