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:
parent
bba7bbc8a2
commit
26f9da5218
8 changed files with 175 additions and 62 deletions
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue