diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index d607a32..5054104 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use uuid::Uuid; use worker::{query, D1PreparedStatement, Env}; -use super::{get_batch_size, server_password_iterations}; +use super::{get_batch_size, server_password_iterations, two_factor_enabled}; use crate::{ auth::Claims, crypto::{generate_salt, hash_password_for_storage}, @@ -331,6 +331,18 @@ pub async fn revision_date( Ok(Json(revision_date)) } +/// GET /api/accounts/tasks +/// +/// Vaultwarden returns an empty list here; some official clients call this endpoint. +/// We don't implement task workflows, so always return an empty list. +#[worker::send] +pub async fn get_tasks() -> Result, AppError> { + Ok(Json(json!({ + "data": [], + "object": "list" + }))) +} + #[worker::send] pub async fn get_profile( claims: Claims, @@ -341,12 +353,13 @@ pub async fn get_profile( let user: User = db .prepare("SELECT * FROM users WHERE id = ?1") - .bind(&[user_id.into()])? + .bind(&[user_id.clone().into()])? .first(None) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; - let profile = Profile::from_user(user)?; + let two_factor_enabled = two_factor_enabled(&db, &user_id).await?; + let profile = Profile::from_user(user, two_factor_enabled)?; Ok(Json(profile)) } @@ -392,7 +405,8 @@ pub async fn post_profile( .await .map_err(|_| AppError::Database)?; - let profile = Profile::from_user(user)?; + let two_factor_enabled = two_factor_enabled(&db, user_id).await?; + let profile = Profile::from_user(user, two_factor_enabled)?; Ok(Json(profile)) } @@ -450,7 +464,8 @@ pub async fn put_avatar( .await .map_err(|_| AppError::Database)?; - let profile = Profile::from_user(user)?; + let two_factor_enabled = two_factor_enabled(&db, user_id).await?; + let profile = Profile::from_user(user, two_factor_enabled)?; Ok(Json(profile)) } diff --git a/src/handlers/config.rs b/src/handlers/config.rs index 4c3105e..d5f53a2 100644 --- a/src/handlers/config.rs +++ b/src/handlers/config.rs @@ -40,7 +40,8 @@ pub async fn config( // We should make sure that we keep this updated when we support the new server features // Version history: // - Individual cipher key encryption: 2024.2.0 - "version": "2025.6.0", + // - Mobile app support for MasterPasswordUnlockData: 2025.8.0 + "version": "2025.12.0", "gitHash": "5d84f176", "server": { "name": "Vaultwarden", @@ -53,8 +54,8 @@ pub async fn config( "vault": domain, "api": format!("{domain}/api"), "identity": format!("{domain}/identity"), - "notifications": format!("{domain}/notifications"), - "sso": format!("{domain}/sso"), + "notifications": format!(""), + "sso": format!(""), "cloudRegion": null, }, // Bitwarden uses this for the self-hosted servers to indicate the default push technology diff --git a/src/handlers/identity.rs b/src/handlers/identity.rs index ca40910..4d086e0 100644 --- a/src/handlers/identity.rs +++ b/src/handlers/identity.rs @@ -11,7 +11,11 @@ use crate::{ crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp}, db, error::AppError, - handlers::{allow_totp_drift, server_password_iterations}, + handlers::{ + allow_totp_drift, + server_password_iterations, + twofactor::{is_twofactor_enabled, list_user_twofactors}, + }, models::twofactor::{RememberTokenData, TwoFactor, TwoFactorType}, models::user::User, }; @@ -94,6 +98,8 @@ pub struct TokenResponse { force_password_reset: bool, #[serde(rename = "UserDecryptionOptions")] user_decryption_options: UserDecryptionOptions, + #[serde(rename = "AccountKeys")] + account_keys: serde_json::Value, #[serde(rename = "TwoFactorToken", skip_serializing_if = "Option::is_none")] two_factor_token: Option, } @@ -102,6 +108,8 @@ pub struct TokenResponse { #[serde(rename_all = "PascalCase")] pub struct UserDecryptionOptions { pub has_master_password: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub master_password_unlock: Option, pub object: String, } @@ -151,6 +159,34 @@ fn generate_tokens_and_response( &EncodingKey::from_secret(jwt_refresh_secret.as_ref()), )?; + let has_master_password = !user.master_password_hash.is_empty(); + let master_password_unlock = if has_master_password { + Some(serde_json::json!({ + "Kdf": { + "KdfType": user.kdf_type, + "Iterations": user.kdf_iterations, + "Memory": user.kdf_memory, + "Parallelism": user.kdf_parallelism + }, + // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. + // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 + "MasterKeyEncryptedUserKey": user.key, + "MasterKeyWrappedUserKey": user.key, + "Salt": user.email + })) + } else { + None + }; + + let account_keys = serde_json::json!({ + "publicKeyEncryptionKeyPair": { + "wrappedPrivateKey": user.private_key, + "publicKey": user.public_key, + "Object": "publicKeyEncryptionKeyPair" + }, + "Object": "privateKeys" + }); + Ok(Json(TokenResponse { access_token, expires_in: expires_in.num_seconds(), @@ -165,9 +201,11 @@ fn generate_tokens_and_response( force_password_reset: false, reset_master_password: false, user_decryption_options: UserDecryptionOptions { - has_master_password: true, + has_master_password, + master_password_unlock, object: "userDecryptionOptions".to_string(), }, + account_keys, two_factor_token, })) } @@ -215,51 +253,32 @@ pub async fn token( return Err(AppError::Unauthorized("Invalid credentials".to_string())); } - // Check for 2FA - let twofactors: Vec = db - .prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype < 1000") - .bind(&[user.id.clone().into()])? - .all() - .await - .map_err(|_| AppError::Database)? - .results() - .unwrap_or_default(); + // Check for 2FA (TOTP) for this user. + let twofactors: Vec = list_user_twofactors(&db, &user.id).await?; let mut two_factor_remember_token: Option = None; - // Filter out Remember type - it's not a real 2FA provider, just a convenience feature - let real_twofactors: Vec<&TwoFactor> = twofactors - .iter() - .filter(|tf| tf.atype != TwoFactorType::Remember as i32) - .collect(); - - if !real_twofactors.is_empty() { - // 2FA is enabled, need to verify - // Only show real 2FA providers to client (not Remember) - let twofactor_ids: Vec = real_twofactors.iter().map(|tf| tf.atype).collect(); + if is_twofactor_enabled(&twofactors) { + // Only advertise Authenticator (TOTP) as the real provider for now. + let twofactor_ids: Vec = vec![TwoFactorType::Authenticator as i32]; let selected_id = payload.two_factor_provider.unwrap_or(twofactor_ids[0]); let twofactor_code = match &payload.two_factor_token { Some(code) => code, None => { // Return 2FA required error - return Err(AppError::TwoFactorRequired(json_err_twofactor( - &twofactor_ids, - ))); + return Err(AppError::TwoFactorRequired(json_err_twofactor(&twofactor_ids))); } }; - // Find the selected twofactor from real_twofactors - let selected_twofactor = real_twofactors - .iter() - .find(|tf| tf.atype == selected_id && tf.enabled) - .copied(); - match TwoFactorType::from_i32(selected_id) { Some(TwoFactorType::Authenticator) => { - let tf = selected_twofactor.ok_or_else(|| { - AppError::BadRequest("TOTP not configured".to_string()) - })?; + let tf = twofactors + .iter() + .find(|tf| { + tf.enabled && tf.atype == TwoFactorType::Authenticator as i32 + }) + .ok_or_else(|| AppError::BadRequest("TOTP not configured".to_string()))?; // Validate TOTP code let allow_drift = allow_totp_drift(&env); @@ -283,10 +302,9 @@ pub async fn token( // Remember is handled separately - client sends remember token from previous login // Check remember token against stored value for this device if let Some(ref device_id) = payload.device_identifier { - // Find remember token in twofactors (not real_twofactors) - let remember_tf = twofactors - .iter() - .find(|tf| tf.atype == TwoFactorType::Remember as i32); + let remember_tf = twofactors.iter().find(|tf| { + tf.enabled && tf.atype == TwoFactorType::Remember as i32 + }); if let Some(tf) = remember_tf { // Parse stored remember tokens as JSON diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index f5b4904..aa35a21 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -70,3 +70,12 @@ pub(crate) fn allow_totp_drift(env: &worker::Env) -> bool { .map(|value| !matches!(value.as_str(), "1" | "true" | "yes" | "on")) .unwrap_or(true) } + +/// Whether the user has 2FA enabled. +pub(crate) async fn two_factor_enabled( + db: &worker::D1Database, + user_id: &str, +) -> Result { + let twofactors = crate::handlers::twofactor::list_user_twofactors(db, user_id).await?; + Ok(crate::handlers::twofactor::is_twofactor_enabled(&twofactors)) +} diff --git a/src/handlers/sync.rs b/src/handlers/sync.rs index e1aa6a3..0498c6c 100644 --- a/src/handlers/sync.rs +++ b/src/handlers/sync.rs @@ -6,7 +6,7 @@ use crate::{ auth::Claims, db, error::AppError, - handlers::{attachments, ciphers}, + handlers::{attachments, ciphers, two_factor_enabled}, models::{ folder::{Folder, FolderResponse}, sync::Profile, @@ -15,6 +15,7 @@ use crate::{ }; use ciphers::RawJson; +use serde_json::{json, Value}; #[worker::send] pub async fn get_sync_data( @@ -32,6 +33,29 @@ pub async fn get_sync_data( .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + let two_factor_enabled = two_factor_enabled(&db, &user_id).await?; + + let has_master_password = !user.master_password_hash.is_empty(); + let master_password_unlock = if has_master_password { + // Mirrors vaultwarden's `ciphers::sync` casing (lower camelCase). + // We don't support SSO, so this is always derived from the current user record. + json!({ + "kdf": { + "kdfType": user.kdf_type, + "iterations": user.kdf_iterations, + "memory": user.kdf_memory, + "parallelism": user.kdf_parallelism + }, + // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. + // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 + "masterKeyEncryptedUserKey": user.key, + "masterKeyWrappedUserKey": user.key, + "salt": user.email + }) + } else { + Value::Null + }; + // Fetch folders let folders_db: Vec = db .prepare("SELECT * FROM folders WHERE user_id = ?1") @@ -54,14 +78,22 @@ pub async fn get_sync_data( .await?; // Serialize profile and folders (small data, acceptable CPU cost) - let profile = Profile::from_user(user)?; + let mut profile = Profile::from_user(user, two_factor_enabled)?; + // Match vaultwarden semantics: `_status` is `Invited` when no master password is set. + // We don't implement org invitations, but this helps clients interpret the account state. + profile.status = if has_master_password { 0 } else { 1 }; let profile_json = serde_json::to_string(&profile).map_err(|_| AppError::Internal)?; let folders_json = serde_json::to_string(&folders).map_err(|_| AppError::Internal)?; // Build response JSON via string concatenation (ciphers already raw JSON) + let user_decryption_json = serde_json::to_string(&json!({ + "masterPasswordUnlock": master_password_unlock + })) + .map_err(|_| AppError::Internal)?; + let response = format!( - r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":null,"sends":[],"object":"sync"}}"#, - profile_json, folders_json, ciphers_json + r#"{{"profile":{},"folders":{},"collections":[],"policies":[],"ciphers":{},"domains":null,"sends":[],"userDecryption":{},"object":"sync"}}"#, + profile_json, folders_json, ciphers_json, user_decryption_json ); Ok(RawJson(response)) diff --git a/src/handlers/twofactor.rs b/src/handlers/twofactor.rs index 06035f6..862261a 100644 --- a/src/handlers/twofactor.rs +++ b/src/handlers/twofactor.rs @@ -16,6 +16,30 @@ use crate::{ models::user::{PasswordOrOtpData, User}, }; +/// List all 2FA records for a user (includes Remember tokens, excludes atype >= 1000). +pub(crate) async fn list_user_twofactors( + db: &worker::D1Database, + user_id: &str, +) -> Result, AppError> { + db.prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype < 1000") + .bind(&[user_id.to_string().into()])? + .all() + .await + .map_err(|_| AppError::Database)? + .results::() + .map_err(|_| AppError::Database) +} + +/// Whether the user has 2FA enabled. +/// +/// For now, we intentionally only treat Authenticator (TOTP) as a real 2FA provider. +/// Remember-device tokens are never considered a 2FA method by themselves. +pub(crate) fn is_twofactor_enabled(twofactors: &[TwoFactor]) -> bool { + twofactors.iter().any(|tf| { + tf.enabled && tf.atype == TwoFactorType::Authenticator as i32 + }) +} + /// GET /api/two-factor - Get all enabled 2FA providers for current user #[worker::send] pub async fn get_twofactor( @@ -24,17 +48,8 @@ pub async fn get_twofactor( ) -> Result, AppError> { let db = db::get_db(&env)?; - let twofactors: Vec = db - .prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype < 1000") - .bind(&[user_id.clone().into()])? - .all() - .await - .map_err(|_| AppError::Database)? - .results::() - .map_err(|_| AppError::Database)? - .iter() - .map(|tf| tf.to_json_provider()) - .collect(); + let twofactors = list_user_twofactors(&db, &user_id).await?; + let twofactors: Vec = twofactors.iter().map(|tf| tf.to_json_provider()).collect(); Ok(Json(serde_json::json!({ "data": twofactors, diff --git a/src/models/sync.rs b/src/models/sync.rs index a33703d..b25f691 100644 --- a/src/models/sync.rs +++ b/src/models/sync.rs @@ -13,6 +13,12 @@ pub struct Profile { pub avatar_color: Option, pub email: String, pub id: String, + pub kdf: i32, + pub kdf_iterations: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub kdf_memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kdf_parallelism: Option, #[serde(skip_serializing_if = "Option::is_none")] pub master_password_hint: Option, pub security_stamp: String, @@ -26,10 +32,18 @@ pub struct Profile { pub creation_date: String, pub private_key: String, pub key: String, + #[serde(default)] + pub organizations: Vec, + #[serde(default)] + pub providers: Vec, + #[serde(default)] + pub provider_organizations: Vec, + #[serde(rename = "_status")] + pub status: i32, } impl Profile { - pub fn from_user(user: User) -> Result { + pub fn from_user(user: User, two_factor_enabled: bool) -> Result { let creation_date = chrono::DateTime::parse_from_rfc3339(&user.created_at) .map_err(|_| AppError::Internal)? .to_rfc3339_opts(SecondsFormat::Micros, true); @@ -39,18 +53,26 @@ impl Profile { name: user.name, avatar_color: user.avatar_color, email: user.email, + kdf: user.kdf_type, + kdf_iterations: user.kdf_iterations, + kdf_memory: user.kdf_memory, + kdf_parallelism: user.kdf_parallelism, master_password_hint: user.master_password_hint, security_stamp: user.security_stamp, object: "profile".to_string(), premium_from_organization: false, force_password_reset: false, email_verified: true, - two_factor_enabled: false, + two_factor_enabled, premium: true, uses_key_connector: false, creation_date, private_key: user.private_key, key: user.key, + organizations: Vec::new(), + providers: Vec::new(), + provider_organizations: Vec::new(), + status: 0, }) } } diff --git a/src/router.rs b/src/router.rs index cac8d9f..c11811d 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/tasks", get(accounts::get_tasks)) .route("/api/accounts/profile", get(accounts::get_profile)) .route("/api/accounts/profile", post(accounts::post_profile)) .route("/api/accounts/profile", put(accounts::put_profile))