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,
|
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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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> {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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!({})))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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" => {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue