feat: implement password salting and PBKDF2 hashing for user registration and authentication

This commit is contained in:
qaz741wsd856 2025-11-29 14:42:40 +00:00
parent 1c36439bed
commit 16a2a7421b
7 changed files with 196 additions and 61 deletions

View file

@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS users (
email_verified BOOLEAN NOT NULL DEFAULT 0, email_verified BOOLEAN NOT NULL DEFAULT 0,
master_password_hash TEXT NOT NULL, master_password_hash TEXT NOT NULL,
master_password_hint TEXT, 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 key TEXT NOT NULL, -- The encrypted symmetric key
private_key TEXT NOT NULL, -- encrypted asymmetric private_key private_key TEXT NOT NULL, -- encrypted asymmetric private_key
public_key TEXT NOT NULL, -- asymmetric public_key public_key TEXT NOT NULL, -- asymmetric public_key

View file

@ -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 js_sys::Uint8Array;
use wasm_bindgen::JsValue; use wasm_bindgen::{JsCast, JsValue};
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::{CryptoKey, SubtleCrypto, WorkerGlobalScope}; use web_sys::{Crypto, CryptoKey, SubtleCrypto};
use worker::js_sys; use worker::js_sys;
use crate::error::AppError; use crate::error::AppError;
pub fn worker_global() -> Option<WorkerGlobalScope> { /// Number of PBKDF2 iterations for server-side password hashing
use wasm_bindgen::JsCast; 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::<WorkerGlobalScope>().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<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. /// Gets the SubtleCrypto interface from the global scope.
fn subtle_crypto() -> Result<SubtleCrypto, AppError> { fn subtle_crypto() -> Result<SubtleCrypto, AppError> {
Ok(worker_global() Ok(get_crypto()?.subtle())
.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())
} }
/// Derives a key using PBKDF2-SHA256. /// Derives a key using PBKDF2-SHA256.
@ -72,10 +83,48 @@ pub async fn pbkdf2_sha256(
Ok(js_sys::Uint8Array::new(&derived_bits).to_vec()) Ok(js_sys::Uint8Array::new(&derived_bits).to_vec())
} }
/// Generates a hash of the master key for password verification. /// Generates a cryptographically secure random salt.
pub async fn hash_master_key( pub fn generate_salt() -> Result<String, AppError> {
master_key: &[u8], let crypto = get_crypto()?;
master_password: &[u8], let salt = Uint8Array::new_with_length(SALT_LENGTH as u32);
) -> Result<Vec<u8>, AppError> { crypto
pbkdf2_sha256(master_key, master_password, 1, 256).await .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<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,
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<bool, AppError> {
let computed_hash = hash_password_for_storage(client_password_hash, salt).await?;
Ok(constant_time_eq(
computed_hash.as_bytes(),
stored_hash.as_bytes(),
))
} }

View file

@ -5,3 +5,29 @@ use worker::{D1Database, Env};
pub fn get_db(env: &Arc<Env>) -> Result<D1Database, AppError> { pub fn get_db(env: &Arc<Env>) -> Result<D1Database, AppError> {
env.d1("vault1").map_err(AppError::Worker) 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);
}
}
}

View file

@ -7,10 +7,11 @@ use uuid::Uuid;
use worker::{query, Env}; use worker::{query, Env};
use crate::{ use crate::{
auth::Claims,
crypto::{generate_salt, hash_password_for_storage, verify_password},
db, db,
error::AppError, error::AppError,
models::user::{DeleteAccountRequest, PreloginResponse, RegisterRequest, User}, models::user::{DeleteAccountRequest, PreloginResponse, RegisterRequest, User},
auth::Claims,
}; };
#[worker::send] #[worker::send]
@ -54,6 +55,12 @@ pub async fn register(
{ {
return Err(AppError::Unauthorized("Not allowed to signup".to_string())); 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 db = db::get_db(&env)?;
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
let user = User { let user = User {
@ -61,8 +68,9 @@ pub async fn register(
name: payload.name, name: payload.name,
email: payload.email.to_lowercase(), email: payload.email.to_lowercase(),
email_verified: false, email_verified: false,
master_password_hash: payload.master_password_hash, master_password_hash: hashed_password,
master_password_hint: payload.master_password_hint, master_password_hint: payload.master_password_hint,
password_salt: Some(password_salt),
key: payload.user_symmetric_key, key: payload.user_symmetric_key,
private_key: payload.user_asymmetric_keys.encrypted_private_key, private_key: payload.user_asymmetric_keys.encrypted_private_key,
public_key: payload.user_asymmetric_keys.public_key, public_key: payload.user_asymmetric_keys.public_key,
@ -73,14 +81,15 @@ pub async fn register(
updated_at: now, updated_at: now,
}; };
let query = query!( query!(
&db, &db,
"INSERT INTO users (id, name, email, master_password_hash, key, private_key, public_key, kdf_iterations, security_stamp, created_at, updated_at) "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)", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
user.id, user.id,
user.name, user.name,
user.email, user.email,
user.master_password_hash, user.master_password_hash,
user.password_salt,
user.key, user.key,
user.private_key, user.private_key,
user.public_key, user.public_key,
@ -88,12 +97,12 @@ pub async fn register(
user.security_stamp, user.security_stamp,
user.created_at, user.created_at,
user.updated_at user.updated_at
).map_err(|error|{ ).map_err(|_|{
AppError::Database AppError::Database
})? })?
.run() .run()
.await .await
.map_err(|error|{ .map_err(|_|{
AppError::Database AppError::Database
})?; })?;
@ -149,44 +158,42 @@ pub async fn delete_account(
let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?;
// Verify the master password hash // 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()))?; .ok_or_else(|| AppError::BadRequest("Missing master password hash".to_string()))?;
if !constant_time_eq(
user.master_password_hash.as_bytes(), let is_valid = if let Some(ref salt) = user.password_salt {
provided_hash.as_bytes(), // 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())); return Err(AppError::Unauthorized("Invalid password".to_string()));
} }
// Delete all user's ciphers // Delete all user's ciphers
query!( query!(&db, "DELETE FROM ciphers WHERE user_id = ?1", user_id)
&db, .map_err(|_| AppError::Database)?
"DELETE FROM ciphers WHERE user_id = ?1", .run()
user_id .await?;
)
.map_err(|_| AppError::Database)?
.run()
.await?;
// Delete all user's folders // Delete all user's folders
query!( query!(&db, "DELETE FROM folders WHERE user_id = ?1", user_id)
&db, .map_err(|_| AppError::Database)?
"DELETE FROM folders WHERE user_id = ?1", .run()
user_id .await?;
)
.map_err(|_| AppError::Database)?
.run()
.await?;
// Delete the user // Delete the user
query!( query!(&db, "DELETE FROM users WHERE id = ?1", user_id)
&db, .map_err(|_| AppError::Database)?
"DELETE FROM users WHERE id = ?1", .run()
user_id .await?;
)
.map_err(|_| AppError::Database)?
.run()
.await?;
Ok(Json(json!({}))) Ok(Json(json!({})))
} }

View file

@ -5,9 +5,15 @@ use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; 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)] #[derive(Debug, Deserialize)]
pub struct TokenRequest { pub struct TokenRequest {
@ -126,22 +132,64 @@ pub async fn token(
.password .password
.ok_or_else(|| AppError::BadRequest("Missing password".to_string()))?; .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") .prepare("SELECT * FROM users WHERE email = ?1")
.bind(&[username.to_lowercase().into()])? .bind(&[username.to_lowercase().into()])?
.first(None) .first(None)
.await .await
.map_err(|_| AppError::Unauthorized("Invalid credentials".to_string()))? .map_err(|_| AppError::Unauthorized("Invalid credentials".to_string()))?
.ok_or_else(|| 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)?; let user: User =
// Securely compare the provided hash with the stored hash serde_json::from_value(user_value).map_err(|_| AppError::Internal)?;
if !constant_time_eq(
user.master_password_hash.as_bytes(), // Check if user needs migration (no salt = legacy user)
password_hash.as_bytes(), 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())); 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) generate_tokens_and_response(user, &env)
} }
"refresh_token" => { "refresh_token" => {

View file

@ -20,6 +20,9 @@ pub async fn main(
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
let _ = console_log::init_with_level(log::Level::Debug); 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. // Allow all origins for CORS, which is typical for a public API like Bitwarden's.
let cors = CorsLayer::new() let cors = CorsLayer::new()
.allow_methods(Any) .allow_methods(Any)

View file

@ -9,6 +9,7 @@ pub struct User {
pub email_verified: bool, pub email_verified: bool,
pub master_password_hash: String, pub master_password_hash: String,
pub master_password_hint: Option<String>, pub master_password_hint: Option<String>,
pub password_salt: Option<String>, // Salt for server-side PBKDF2 (NULL for legacy users)
pub key: String, pub key: String,
pub private_key: String, pub private_key: String,
pub public_key: String, pub public_key: String,