feat: add password change and key rotation endpoints for user accounts
This commit is contained in:
parent
6f842cc2ce
commit
9a028169b7
4 changed files with 367 additions and 44 deletions
|
|
@ -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!({})))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue