diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index 62ab338..5d7b3c9 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -1,17 +1,23 @@ use axum::{extract::State, Json}; use chrono::Utc; -use constant_time_eq::constant_time_eq; use serde_json::{json, Value}; use std::sync::Arc; use uuid::Uuid; -use worker::{query, Env}; +use worker::{query, D1PreparedStatement, Env}; +use super::get_batch_size; use crate::{ auth::Claims, - crypto::{generate_salt, hash_password_for_storage, verify_password}, + crypto::{generate_salt, hash_password_for_storage}, db, error::AppError, - models::user::{DeleteAccountRequest, PreloginResponse, RegisterRequest, User}, + models::{ + cipher::CipherData, + user::{ + ChangePasswordRequest, DeleteAccountRequest, PreloginResponse, RegisterRequest, + RotateKeyRequest, User, + }, + }, }; #[worker::send] @@ -119,22 +125,22 @@ pub async fn revision_date( claims: Claims, State(env): State>, ) -> Result, AppError> { - let db = db::get_db(&env)? ; - + let db = db::get_db(&env)?; + // get the user's updated_at timestamp let updated_at: Option = db .prepare("SELECT updated_at FROM users WHERE id = ?1") - .bind(&[claims.sub. into()])? + .bind(&[claims.sub.into()])? .first(Some("updated_at")) .await .map_err(|_| AppError::Database)?; - + // convert the timestamp to a millisecond-level Unix timestamp let revision_date = updated_at - .and_then(|ts| chrono::DateTime::parse_from_rfc3339(&ts). ok()) - . map(|dt| dt.timestamp_millis()) + .and_then(|ts| chrono::DateTime::parse_from_rfc3339(&ts).ok()) + .map(|dt| dt.timestamp_millis()) .unwrap_or_else(|| chrono::Utc::now().timestamp_millis()); - + Ok(Json(revision_date)) } @@ -162,18 +168,9 @@ pub async fn delete_account( .master_password_hash .ok_or_else(|| AppError::BadRequest("Missing master password hash".to_string()))?; - 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(), - ) - }; + let verification = user.verify_master_password(&provided_hash).await?; - if !is_valid { + if !verification.is_valid() { return Err(AppError::Unauthorized("Invalid password".to_string())); } @@ -197,3 +194,183 @@ pub async fn delete_account( Ok(Json(json!({}))) } + +/// POST /accounts/password - Change master password +#[worker::send] +pub async fn post_password( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + let user_id = &claims.sub; + + // Get the user from the database + let user: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; + + // Verify the current master password + let verification = user + .verify_master_password(&payload.master_password_hash) + .await?; + + if !verification.is_valid() { + return Err(AppError::Unauthorized("Invalid password".to_string())); + } + + // Generate new salt and hash the new password + let new_salt = generate_salt()?; + let new_hashed_password = + hash_password_for_storage(&payload.new_master_password_hash, &new_salt).await?; + + // Generate new security stamp and update timestamp + let new_security_stamp = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + + // Update user record + query!( + &db, + "UPDATE users SET master_password_hash = ?1, password_salt = ?2, key = ?3, master_password_hint = ?4, security_stamp = ?5, updated_at = ?6 WHERE id = ?7", + new_hashed_password, + new_salt, + payload.key, + payload.master_password_hint, + new_security_stamp, + now, + user_id + ) + .map_err(|_| AppError::Database)? + .run() + .await?; + + Ok(Json(json!({}))) +} + +/// POST /accounts/key-management/rotate-user-account-keys - Rotate user encryption keys +#[worker::send] +pub async fn post_rotatekey( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + let user_id = &claims.sub; + let batch_size = get_batch_size(&env); + + // Get the user from the database + let user: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[user_id.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; + + // Verify the current master password + let verification = user + .verify_master_password(&payload.old_master_key_authentication_hash) + .await?; + + if !verification.is_valid() { + return Err(AppError::Unauthorized("Invalid password".to_string())); + } + + // Validate that email and kdf settings match + let unlock_data = &payload.account_unlock_data.master_password_unlock_data; + if user.email != unlock_data.email { + return Err(AppError::BadRequest( + "Email mismatch in rotation request".to_string(), + )); + } + + let now = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + + // Update all folders with new encrypted names (batch operation) + let mut folder_statements: Vec = + Vec::with_capacity(payload.account_data.folders.len()); + for folder in &payload.account_data.folders { + let stmt = query!( + &db, + "UPDATE folders SET name = ?1, updated_at = ?2 WHERE id = ?3 AND user_id = ?4", + folder.name, + now, + folder.id, + user_id + ) + .map_err(|_| AppError::Database)?; + folder_statements.push(stmt); + } + db::execute_in_batches(&db, folder_statements, batch_size).await?; + + // Update all ciphers with new encrypted data (batch operation) + // Only update personal ciphers (organization_id is None) + let mut cipher_statements: Vec = + Vec::with_capacity(payload.account_data.ciphers.len()); + for cipher in &payload.account_data.ciphers { + // Skip organization ciphers + if cipher.organization_id.is_some() { + continue; + } + + let cipher_data = CipherData { + name: cipher.name.clone(), + notes: cipher.notes.clone(), + login: cipher.login.clone(), + card: cipher.card.clone(), + identity: cipher.identity.clone(), + secure_note: cipher.secure_note.clone(), + fields: cipher.fields.clone(), + password_history: cipher.password_history.clone(), + reprompt: cipher.reprompt, + }; + + let data = serde_json::to_string(&cipher_data).map_err(|_| AppError::Internal)?; + + let stmt = query!( + &db, + "UPDATE ciphers SET data = ?1, folder_id = ?2, favorite = ?3, updated_at = ?4 WHERE id = ?5 AND user_id = ?6", + data, + cipher.folder_id, + cipher.favorite, + now, + cipher.id, + user_id + ) + .map_err(|_| AppError::Database)?; + cipher_statements.push(stmt); + } + db::execute_in_batches(&db, cipher_statements, batch_size).await?; + + // Generate new salt and hash the new password + let new_salt = generate_salt()?; + let new_hashed_password = + hash_password_for_storage(&unlock_data.master_key_authentication_hash, &new_salt).await?; + + // Generate new security stamp + let new_security_stamp = Uuid::new_v4().to_string(); + + // Update user record with new keys and password + query!( + &db, + "UPDATE users SET master_password_hash = ?1, password_salt = ?2, key = ?3, private_key = ?4, security_stamp = ?5, updated_at = ?6 WHERE id = ?7", + new_hashed_password, + new_salt, + unlock_data.master_key_encrypted_user_key, + payload.account_keys.user_key_encrypted_account_private_key, + new_security_stamp, + now, + user_id + ) + .map_err(|_| AppError::Database)? + .run() + .await?; + + Ok(Json(json!({}))) +} diff --git a/src/handlers/identity.rs b/src/handlers/identity.rs index 62bab19..151d463 100644 --- a/src/handlers/identity.rs +++ b/src/handlers/identity.rs @@ -1,6 +1,5 @@ use axum::{extract::State, Form, Json}; use chrono::{Duration, Utc}; -use constant_time_eq::constant_time_eq; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -9,7 +8,7 @@ use worker::{query, Env}; use crate::{ auth::Claims, - crypto::{generate_salt, hash_password_for_storage, verify_password}, + crypto::{generate_salt, hash_password_for_storage}, db, error::AppError, models::user::User, @@ -139,27 +138,16 @@ pub async fn token( .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_value).map_err(|_| AppError::Internal)?; + 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(), - ) - }; + let verification = user.verify_master_password(&password_hash).await?; - if !is_valid { + if !verification.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() { + let user = if verification.needs_migration() { // Generate new salt and hash the password let new_salt = generate_salt()?; let new_hash = hash_password_for_storage(&password_hash, &new_salt).await?; @@ -219,4 +207,4 @@ pub async fn token( } _ => Err(AppError::BadRequest("Unsupported grant_type".to_string())), } -} \ No newline at end of file +} diff --git a/src/models/user.rs b/src/models/user.rs index 384527c..ed102cd 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -1,5 +1,8 @@ +use constant_time_eq::constant_time_eq; use serde::{Deserialize, Serialize}; +use crate::{crypto::verify_password, error::AppError}; + #[derive(Debug, Serialize, Deserialize)] pub struct User { pub id: String, @@ -20,6 +23,53 @@ pub struct User { pub updated_at: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PasswordVerification { + MatchCurrentScheme, + MatchLegacyScheme, + Mismatch, +} + +impl PasswordVerification { + pub fn is_valid(&self) -> bool { + matches!( + self, + PasswordVerification::MatchCurrentScheme | PasswordVerification::MatchLegacyScheme + ) + } + + pub fn needs_migration(&self) -> bool { + matches!(self, PasswordVerification::MatchLegacyScheme) + } +} + +impl User { + pub async fn verify_master_password( + &self, + provided_hash: &str, + ) -> Result { + if let Some(ref salt) = self.password_salt { + let is_valid = verify_password(provided_hash, &self.master_password_hash, salt).await?; + Ok(if is_valid { + PasswordVerification::MatchCurrentScheme + } else { + PasswordVerification::Mismatch + }) + } else { + let is_valid = constant_time_eq( + self.master_password_hash.as_bytes(), + provided_hash.as_bytes(), + ); + + Ok(if is_valid { + PasswordVerification::MatchLegacyScheme + } else { + PasswordVerification::Mismatch + }) + } + } +} + mod bool_from_int { use serde::{self, Deserialize, Deserializer, Serializer}; @@ -83,4 +133,96 @@ pub struct DeleteAccountRequest { #[serde(alias = "MasterPasswordHash")] pub master_password_hash: Option, pub otp: Option, -} \ No newline at end of file +} + +// For POST /accounts/password request +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangePasswordRequest { + pub master_password_hash: String, + pub new_master_password_hash: String, + pub master_password_hint: Option, + pub key: String, +} + +// For POST /accounts/key-management/rotate-user-account-keys request +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RotateKeyRequest { + pub account_unlock_data: RotateAccountUnlockData, + pub account_keys: RotateAccountKeys, + pub account_data: RotateAccountData, + pub old_master_key_authentication_hash: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RotateAccountUnlockData { + pub master_password_unlock_data: MasterPasswordUnlockData, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MasterPasswordUnlockData { + pub kdf_type: i32, + pub kdf_iterations: i32, + pub kdf_parallelism: Option, + pub kdf_memory: Option, + pub email: String, + pub master_key_authentication_hash: String, + pub master_key_encrypted_user_key: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RotateAccountKeys { + pub user_key_encrypted_account_private_key: String, + pub account_public_key: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RotateAccountData { + pub ciphers: Vec, + pub folders: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RotateCipherData { + pub id: String, + #[serde(rename = "type")] + pub r#type: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub folder_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub organization_id: Option, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(default)] + pub favorite: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub login: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub card: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub identity: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secure_note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fields: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password_history: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reprompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RotateFolderData { + pub id: String, + pub name: String, +} diff --git a/src/router.rs b/src/router.rs index d490735..96aafde 100644 --- a/src/router.rs +++ b/src/router.rs @@ -29,6 +29,13 @@ pub fn api_router(env: Env) -> Router { // Delete account .route("/api/accounts", delete(accounts::delete_account)) .route("/api/accounts/delete", post(accounts::delete_account)) + // Change password + .route("/api/accounts/password", post(accounts::post_password)) + // Rotate encryption keys + .route( + "/api/accounts/key-management/rotate-user-account-keys", + post(accounts::post_rotatekey), + ) // Ciphers CRUD .route("/api/ciphers", post(ciphers::create_cipher_simple)) .route("/api/ciphers/create", post(ciphers::create_cipher)) @@ -38,11 +45,20 @@ pub fn api_router(env: Env) -> Router { .route("/api/ciphers/{id}/delete", put(ciphers::soft_delete_cipher)) // Cipher hard delete (DELETE/POST permanently removes cipher) .route("/api/ciphers/{id}", delete(ciphers::hard_delete_cipher)) - .route("/api/ciphers/{id}/delete", post(ciphers::hard_delete_cipher)) + .route( + "/api/ciphers/{id}/delete", + post(ciphers::hard_delete_cipher), + ) // Cipher bulk soft delete - .route("/api/ciphers/delete", put(ciphers::soft_delete_ciphers_bulk)) + .route( + "/api/ciphers/delete", + put(ciphers::soft_delete_ciphers_bulk), + ) // Cipher bulk hard delete - .route("/api/ciphers/delete", post(ciphers::hard_delete_ciphers_bulk)) + .route( + "/api/ciphers/delete", + post(ciphers::hard_delete_ciphers_bulk), + ) .route("/api/ciphers", delete(ciphers::hard_delete_ciphers_bulk)) // Cipher restore (clears deleted_at) .route("/api/ciphers/{id}/restore", put(ciphers::restore_cipher))