feat: implement password salting and PBKDF2 hashing for user registration and authentication
This commit is contained in:
parent
1c36439bed
commit
16a2a7421b
7 changed files with 196 additions and 61 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
26
src/db.rs
26
src/db.rs
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({})))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue