From fe298c924646c93239a36a6c1146e86fe3e6c362 Mon Sep 17 00:00:00 2001 From: qaz741wsd856 Date: Sun, 11 Jan 2026 18:31:20 +0000 Subject: [PATCH] feat: add password hint endpoint to accounts API for direct retrieval of master password hints --- src/handlers/accounts.rs | 60 ++++++++++++++++++++++++++++++++++++++-- src/models/sync.rs | 3 -- src/models/user.rs | 7 +++++ src/router.rs | 1 + 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index f334fb4..f8f1cb9 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -18,8 +18,8 @@ use crate::{ sync::Profile, user::{ AvatarData, ChangeKdfRequest, ChangePasswordRequest, MasterPasswordUnlockData, - PasswordOrOtpData, PreloginResponse, ProfileData, RegisterRequest, RotateKeyRequest, - User, + PasswordHintRequest, PasswordOrOtpData, PreloginResponse, ProfileData, RegisterRequest, + RotateKeyRequest, User, }, }, }; @@ -311,6 +311,62 @@ pub async fn send_verification_email() -> Result, AppError> { Ok(Json("fixed-token-to-mock".to_string())) } +/// POST /api/accounts/password-hint +/// +/// Bitwarden normally sends the master password hint via email. This project does not implement +/// email delivery, so we return the hint directly. +#[worker::send] +pub async fn password_hint( + State(env): State>, + headers: HeaderMap, + Json(payload): Json, +) -> Result, AppError> { + // Basic rate limit by IP to slow down bulk email enumeration attempts. + if let Ok(rate_limiter) = env.rate_limiter("LOGIN_RATE_LIMITER") { + let ip = headers + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + .unwrap_or("unknown"); + let rate_limit_key = format!("password-hint:{}", ip); + if let Ok(outcome) = rate_limiter.limit(rate_limit_key).await { + if !outcome.success { + return Err(AppError::TooManyRequests( + "Too many requests. Please try again later.".to_string(), + )); + } + } + } + + const NO_HINT: &str = "Sorry, you have no password hint..."; + + let db = db::get_db(&env)?; + let email = payload.email.to_lowercase(); + + let hint: Option = db + .prepare("SELECT master_password_hint FROM users WHERE email = ?1") + .bind(&[email.into()])? + .first(Some("master_password_hint")) + .await + .map_err(|_| AppError::Database)?; + + let hint = hint.and_then(|h| { + let trimmed = h.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + + if let Some(hint) = hint { + return Err(AppError::BadRequest(format!( + "Your password hint is: {hint}" + ))); + } + + Err(AppError::BadRequest(NO_HINT.to_string())) +} + #[worker::send] pub async fn revision_date( claims: Claims, diff --git a/src/models/sync.rs b/src/models/sync.rs index 62cb216..fbe8f99 100644 --- a/src/models/sync.rs +++ b/src/models/sync.rs @@ -13,8 +13,6 @@ pub struct Profile { pub avatar_color: Option, pub email: String, pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub master_password_hint: Option, pub security_stamp: String, pub object: String, pub premium_from_organization: bool, @@ -47,7 +45,6 @@ impl Profile { name: user.name, avatar_color: user.avatar_color, email: user.email, - master_password_hint: user.master_password_hint, security_stamp: user.security_stamp, object: "profile".to_string(), premium_from_organization: false, diff --git a/src/models/user.rs b/src/models/user.rs index 4d2da57..d9aa694 100644 --- a/src/models/user.rs +++ b/src/models/user.rs @@ -144,6 +144,13 @@ pub struct RegisterRequest { pub kdf_parallelism: Option, // Argon2 parallelism parameter (1-16) } +// For POST /accounts/password-hint request +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasswordHintRequest { + pub email: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KeyData { diff --git a/src/router.rs b/src/router.rs index 6b98f25..8d52efb 100644 --- a/src/router.rs +++ b/src/router.rs @@ -30,6 +30,7 @@ pub fn api_router(env: Env) -> Router { .route("/api/sync", get(sync::get_sync_data)) // For on-demand sync checks .route("/api/accounts/revision-date", get(accounts::revision_date)) + .route("/api/accounts/password-hint", post(accounts::password_hint)) .route("/api/accounts/tasks", get(accounts::get_tasks)) .route("/api/accounts/profile", get(accounts::get_profile)) .route("/api/accounts/profile", post(accounts::post_profile))