diff --git a/sql/schema.sql b/sql/schema.sql index 2e086e1..a01f78a 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS users ( email_verified BOOLEAN NOT NULL DEFAULT 0, master_password_hash TEXT NOT NULL, master_password_hint TEXT, + password_salt TEXT, -- Salt for server-side PBKDF2 hashing (NULL for legacy users pending migration) key TEXT NOT NULL, -- The encrypted symmetric key private_key TEXT NOT NULL, -- encrypted asymmetric private_key public_key TEXT NOT NULL, -- asymmetric public_key diff --git a/src/crypto.rs b/src/crypto.rs index 3cc4ca5..d73b438 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -1,24 +1,35 @@ +use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; +use constant_time_eq::constant_time_eq; use js_sys::Uint8Array; -use wasm_bindgen::JsValue; +use wasm_bindgen::{JsCast, JsValue}; use wasm_bindgen_futures::JsFuture; -use web_sys::{CryptoKey, SubtleCrypto, WorkerGlobalScope}; +use web_sys::{Crypto, CryptoKey, SubtleCrypto}; use worker::js_sys; use crate::error::AppError; -pub fn worker_global() -> Option { - use wasm_bindgen::JsCast; +/// Number of PBKDF2 iterations for server-side password hashing +const SERVER_PBKDF2_ITERATIONS: u32 = 100_000; +/// Salt length in bytes +const SALT_LENGTH: usize = 16; +/// Derived key length in bits +const KEY_LENGTH_BITS: u32 = 256; - js_sys::global().dyn_into::().ok() +/// Gets the Crypto interface from the global scope. +/// Works in Cloudflare Workers by using js_sys::Reflect instead of WorkerGlobalScope. +fn get_crypto() -> Result { + 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::() + .map_err(|_| AppError::Crypto("Failed to cast to Crypto".to_string())) } /// Gets the SubtleCrypto interface from the global scope. fn subtle_crypto() -> Result { - Ok(worker_global() - .ok_or_else(|| AppError::Crypto("Could not get worker global scope".to_string()))? - .crypto() - .map_err(|e| AppError::Crypto(format!("Failed to get crypto: {:?}", e)))? - .subtle()) + Ok(get_crypto()?.subtle()) } /// Derives a key using PBKDF2-SHA256. @@ -72,10 +83,48 @@ pub async fn pbkdf2_sha256( Ok(js_sys::Uint8Array::new(&derived_bits).to_vec()) } -/// Generates a hash of the master key for password verification. -pub async fn hash_master_key( - master_key: &[u8], - master_password: &[u8], -) -> Result, AppError> { - pbkdf2_sha256(master_key, master_password, 1, 256).await +/// Generates a cryptographically secure random salt. +pub fn generate_salt() -> Result { + let crypto = get_crypto()?; + let salt = Uint8Array::new_with_length(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, +) -> Result { + 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, + SERVER_PBKDF2_ITERATIONS, + KEY_LENGTH_BITS, + ) + .await?; + + 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, +) -> Result { + let computed_hash = hash_password_for_storage(client_password_hash, salt).await?; + Ok(constant_time_eq( + computed_hash.as_bytes(), + stored_hash.as_bytes(), + )) } diff --git a/src/db.rs b/src/db.rs index 7416df1..7d069c5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,3 +5,29 @@ use worker::{D1Database, Env}; pub fn get_db(env: &Arc) -> Result { env.d1("vault1").map_err(AppError::Worker) } + +/// Ensures the password_salt column exists in the users table. +/// This provides seamless migration for existing databases. +/// Ignores "duplicate column name" errors if the column already exists. +pub async fn ensure_schema(env: &Env) { + let db = match env.d1("vault1") { + Ok(db) => db, + Err(e) => { + log::error!("Failed to get database: {:?}", e); + return; + } + }; + + // Try to add the column + if let Err(e) = db + .prepare("ALTER TABLE users ADD COLUMN password_salt TEXT") + .run() + .await + { + let err_msg = format!("{:?}", e); + // Ignore "duplicate column name" error (column already exists) + if !err_msg.to_lowercase().contains("duplicate column name") { + log::error!("Failed to ensure schema: {}", err_msg); + } + } +} diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index 847a47a..a03a16b 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -7,10 +7,11 @@ use uuid::Uuid; use worker::{query, Env}; use crate::{ + auth::Claims, + crypto::{generate_salt, hash_password_for_storage, verify_password}, db, error::AppError, models::user::{DeleteAccountRequest, PreloginResponse, RegisterRequest, User}, - auth::Claims, }; #[worker::send] @@ -54,6 +55,12 @@ pub async fn register( { return Err(AppError::Unauthorized("Not allowed to signup".to_string())); } + + // Generate salt and hash the password with server-side PBKDF2 + let password_salt = generate_salt()?; + let hashed_password = + hash_password_for_storage(&payload.master_password_hash, &password_salt).await?; + let db = db::get_db(&env)?; let now = Utc::now().to_rfc3339(); let user = User { @@ -61,8 +68,9 @@ pub async fn register( name: payload.name, email: payload.email.to_lowercase(), email_verified: false, - master_password_hash: payload.master_password_hash, + master_password_hash: hashed_password, master_password_hint: payload.master_password_hint, + password_salt: Some(password_salt), key: payload.user_symmetric_key, private_key: payload.user_asymmetric_keys.encrypted_private_key, public_key: payload.user_asymmetric_keys.public_key, @@ -73,14 +81,15 @@ pub async fn register( updated_at: now, }; - let query = query!( + query!( &db, - "INSERT INTO users (id, name, email, master_password_hash, key, private_key, public_key, kdf_iterations, security_stamp, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + "INSERT INTO users (id, name, email, master_password_hash, password_salt, key, private_key, public_key, kdf_iterations, security_stamp, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", user.id, user.name, user.email, user.master_password_hash, + user.password_salt, user.key, user.private_key, user.public_key, @@ -88,12 +97,12 @@ pub async fn register( user.security_stamp, user.created_at, user.updated_at - ).map_err(|error|{ + ).map_err(|_|{ AppError::Database })? .run() .await - .map_err(|error|{ + .map_err(|_|{ AppError::Database })?; @@ -149,44 +158,42 @@ pub async fn delete_account( let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; // Verify the master password hash - let provided_hash = payload.master_password_hash + let provided_hash = payload + .master_password_hash .ok_or_else(|| AppError::BadRequest("Missing master password hash".to_string()))?; - if !constant_time_eq( - user.master_password_hash.as_bytes(), - provided_hash.as_bytes(), - ) { + + let is_valid = if let Some(ref salt) = user.password_salt { + // New user with PBKDF2 hashed password + verify_password(&provided_hash, &user.master_password_hash, salt).await? + } else { + // Legacy user: direct comparison (migration happens at login, not delete) + constant_time_eq( + user.master_password_hash.as_bytes(), + provided_hash.as_bytes(), + ) + }; + + if !is_valid { return Err(AppError::Unauthorized("Invalid password".to_string())); } // Delete all user's ciphers - query!( - &db, - "DELETE FROM ciphers WHERE user_id = ?1", - user_id - ) - .map_err(|_| AppError::Database)? - .run() - .await?; + query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id) + .map_err(|_| AppError::Database)? + .run() + .await?; // Delete all user's folders - query!( - &db, - "DELETE FROM folders WHERE user_id = ?1", - user_id - ) - .map_err(|_| AppError::Database)? - .run() - .await?; + query!(&db, "DELETE FROM folders WHERE user_id = ?1", user_id) + .map_err(|_| AppError::Database)? + .run() + .await?; // Delete the user - query!( - &db, - "DELETE FROM users WHERE id = ?1", - user_id - ) - .map_err(|_| AppError::Database)? - .run() - .await?; + query!(&db, "DELETE FROM users WHERE id = ?1", user_id) + .map_err(|_| AppError::Database)? + .run() + .await?; Ok(Json(json!({}))) } diff --git a/src/handlers/identity.rs b/src/handlers/identity.rs index 2d945a3..62bab19 100644 --- a/src/handlers/identity.rs +++ b/src/handlers/identity.rs @@ -5,9 +5,15 @@ use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation} use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; -use worker::Env; +use worker::{query, Env}; -use crate::{auth::Claims, db, error::AppError, models::user::User}; +use crate::{ + auth::Claims, + crypto::{generate_salt, hash_password_for_storage, verify_password}, + db, + error::AppError, + models::user::User, +}; #[derive(Debug, Deserialize)] pub struct TokenRequest { @@ -126,22 +132,64 @@ pub async fn token( .password .ok_or_else(|| AppError::BadRequest("Missing password".to_string()))?; - let user: Value = db + let user_value: Value = db .prepare("SELECT * FROM users WHERE email = ?1") .bind(&[username.to_lowercase().into()])? .first(None) .await .map_err(|_| AppError::Unauthorized("Invalid credentials".to_string()))? .ok_or_else(|| AppError::Unauthorized("Invalid credentials".to_string()))?; - let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; - // Securely compare the provided hash with the stored hash - if !constant_time_eq( - user.master_password_hash.as_bytes(), - password_hash.as_bytes(), - ) { + let user: User = + serde_json::from_value(user_value).map_err(|_| AppError::Internal)?; + + // Check if user needs migration (no salt = legacy user) + let is_valid = if let Some(ref salt) = user.password_salt { + // New user with PBKDF2 hashed password + verify_password(&password_hash, &user.master_password_hash, salt).await? + } else { + // Legacy user: direct comparison + constant_time_eq( + user.master_password_hash.as_bytes(), + password_hash.as_bytes(), + ) + }; + + if !is_valid { return Err(AppError::Unauthorized("Invalid credentials".to_string())); } + // Migrate legacy user to PBKDF2 if password matches and no salt exists + let user = if user.password_salt.is_none() { + // Generate new salt and hash the password + let new_salt = generate_salt()?; + let new_hash = hash_password_for_storage(&password_hash, &new_salt).await?; + let now = Utc::now().to_rfc3339(); + + // Update user in database + query!( + &db, + "UPDATE users SET master_password_hash = ?1, password_salt = ?2, updated_at = ?3 WHERE id = ?4", + &new_hash, + &new_salt, + &now, + &user.id + ) + .map_err(|_| AppError::Database)? + .run() + .await + .map_err(|_| AppError::Database)?; + + // Return updated user + User { + master_password_hash: new_hash, + password_salt: Some(new_salt), + updated_at: now, + ..user + } + } else { + user + }; + generate_tokens_and_response(user, &env) } "refresh_token" => { diff --git a/src/lib.rs b/src/lib.rs index 806752f..7bf3e20 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,9 @@ pub async fn main( console_error_panic_hook::set_once(); let _ = console_log::init_with_level(log::Level::Debug); + // Ensure database schema is up to date (adds password_salt column if missing) + db::ensure_schema(&env).await; + // Allow all origins for CORS, which is typical for a public API like Bitwarden's. let cors = CorsLayer::new() .allow_methods(Any) diff --git a/src/models/user.rs b/src/models/user.rs index b795bf5..384527c 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -9,6 +9,7 @@ pub struct User { pub email_verified: bool, pub master_password_hash: String, pub master_password_hint: Option, + pub password_salt: Option, // Salt for server-side PBKDF2 (NULL for legacy users) pub key: String, pub private_key: String, pub public_key: String,