feat: align with vaultwarden 1.35.0

**Synced Vaultwarden Changes**:
- `061d320c` - Add new accountKeys and masterPasswordUnlock fields
- `5c91058b` - Add UserDecryptionOptions on /sync too
- `57bdab15` - add empty /api/tasks endpoint
- `0ab7784b` - Update web-vault to v2025.12.0
This commit is contained in:
qaz741wsd856 2025-12-28 16:50:51 +00:00
parent bba7bbc8a2
commit 26f9da5218
8 changed files with 175 additions and 62 deletions

View file

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

View file

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

View file

@ -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<String>,
}
@ -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<serde_json::Value>,
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<TwoFactor> = 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<TwoFactor> = list_user_twofactors(&db, &user.id).await?;
let mut two_factor_remember_token: Option<String> = 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<i32> = 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<i32> = 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

View file

@ -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<bool, crate::error::AppError> {
let twofactors = crate::handlers::twofactor::list_user_twofactors(db, user_id).await?;
Ok(crate::handlers::twofactor::is_twofactor_enabled(&twofactors))
}

View file

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

View file

@ -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<Vec<TwoFactor>, 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::<TwoFactor>()
.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<Json<Value>, AppError> {
let db = db::get_db(&env)?;
let twofactors: Vec<Value> = db
.prepare("SELECT * FROM twofactor WHERE user_uuid = ?1 AND atype < 1000")
.bind(&[user_id.clone().into()])?
.all()
.await
.map_err(|_| AppError::Database)?
.results::<TwoFactor>()
.map_err(|_| AppError::Database)?
.iter()
.map(|tf| tf.to_json_provider())
.collect();
let twofactors = list_user_twofactors(&db, &user_id).await?;
let twofactors: Vec<Value> = twofactors.iter().map(|tf| tf.to_json_provider()).collect();
Ok(Json(serde_json::json!({
"data": twofactors,

View file

@ -13,6 +13,12 @@ pub struct Profile {
pub avatar_color: Option<String>,
pub email: String,
pub id: String,
pub kdf: i32,
pub kdf_iterations: i32,
#[serde(skip_serializing_if = "Option::is_none")]
pub kdf_memory: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kdf_parallelism: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub master_password_hint: Option<String>,
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<Value>,
#[serde(default)]
pub providers: Vec<Value>,
#[serde(default)]
pub provider_organizations: Vec<Value>,
#[serde(rename = "_status")]
pub status: i32,
}
impl Profile {
pub fn from_user(user: User) -> Result<Self, AppError> {
pub fn from_user(user: User, two_factor_enabled: bool) -> Result<Self, AppError> {
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,
})
}
}

View file

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