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,
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

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 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<WorkerGlobalScope> {
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::<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.
fn subtle_crypto() -> Result<SubtleCrypto, AppError> {
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<Vec<u8>, AppError> {
pbkdf2_sha256(master_key, master_password, 1, 256).await
/// Generates a cryptographically secure random salt.
pub fn generate_salt() -> Result<String, AppError> {
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<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> {
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 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!({})))
}

View file

@ -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" => {

View file

@ -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)

View file

@ -9,6 +9,7 @@ pub struct User {
pub email_verified: bool,
pub master_password_hash: 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 private_key: String,
pub public_key: String,