feat: add password change and key rotation endpoints for user accounts

This commit is contained in:
qaz741wsd856 2025-11-30 08:00:05 +00:00
parent 6f842cc2ce
commit 9a028169b7
4 changed files with 367 additions and 44 deletions

View file

@ -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<Arc<Env>>,
) -> Result<Json<i64>, AppError> {
let db = db::get_db(&env)? ;
let db = db::get_db(&env)?;
// get the user's updated_at timestamp
let updated_at: Option<String> = 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<Arc<Env>>,
Json(payload): Json<ChangePasswordRequest>,
) -> Result<Json<Value>, 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<Arc<Env>>,
Json(payload): Json<RotateKeyRequest>,
) -> Result<Json<Value>, 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<D1PreparedStatement> =
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<D1PreparedStatement> =
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!({})))
}

View file

@ -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())),
}
}
}

View file

@ -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<PasswordVerification, AppError> {
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<String>,
pub otp: Option<String>,
}
}
// 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<String>,
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<i32>,
pub kdf_memory: Option<i32>,
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<RotateCipherData>,
pub folders: Vec<RotateFolderData>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub organization_id: Option<String>,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default)]
pub favorite: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub login: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub card: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identity: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub secure_note: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub fields: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password_history: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reprompt: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RotateFolderData {
pub id: String,
pub name: String,
}

View file

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