Files
vaultwarden/src/api/mod.rs
T
Daniel García 7c82fbed0c Implement V2 key rotation support (#7766)
* Implement V2 key rotation support

* Clear user key ids left stale by key rotations

* Refuse key ids that aren't 32 lowercase hex characters, like upstream

* Save the signature key pair before the v2 account state

* Gate v2 logins on upstream's v2 encryption check

* Document the v2 encryption feature flags

* Remove the v2 upgrade feature flags

With force-upgrade-v2-encryption on, the clients loop on any v1 account that stays v1 on a vault route: the migration check runs fullSync(false), which writes lastSync, which triggers the scheduler again. No server response stops it, so don't serve the flag until the clients fix it.

pm-30144-sdk-key-rotation and pm-31050-no-logout-key-upgrade-rotation only serve that upgrade, so they go too.

* Move the v2 upgrade token migration to its own version

Diesel identifies a migration by the date in its folder name alone, and silently drops one of two folders that share it. The Custom Roles migration uses the same date.

* Refuse rotations from a replaced user key, like upstream

Both rotation endpoints accept the optional oldUserKeyId. When it and the
stored key id are both set and differ, the rotation is refused with a 409
asking the client to sync, before the account keys are checked or anything
is written.

Also correct the comment on saving the signature key pair first: by then the
vault data is already re-encrypted, so a failure there doesn't keep the
account usable.

* Update the v2 client minimum in .env.template and the key pair comments

V2 accounts need 2026.10.1, or 2026.10.2 for the desktop app, so the per-flag versions are only enough to create one. The comments on saving the signature key pair now cover the rotation of an account that is already v2.
2026-11-12 22:30:39 +01:00

145 lines
5.1 KiB
Rust

mod admin;
pub mod core;
mod icons;
mod identity;
mod notifications;
mod push;
mod web;
use rocket::serde::json::Json;
use serde_json::Value;
pub use crate::api::{
admin::catchers as admin_catchers,
admin::routes as admin_routes,
core::catchers as core_catchers,
core::purge_auth_requests,
core::purge_sends,
core::purge_trashed_ciphers,
core::routes as core_routes,
core::two_factor::send_incomplete_2fa_notifications,
core::{emergency_notification_reminder_job, emergency_request_timeout_job},
core::{event_cleanup_job, events_routes as core_events_routes},
icons::routes as icons_routes,
identity::routes as identity_routes,
notifications::routes as notifications_routes,
notifications::{AnonymousNotify, LogOutReason, Notify, UpdateType, WS_ANONYMOUS_SUBSCRIPTIONS, WS_USERS},
push::{
push_cipher_update, push_folder_update, push_logout, push_logout_to_relay, push_send_update, push_user_update,
register_push_device, unregister_push_device,
},
web::catchers as web_catchers,
web::routes as web_routes,
web::{STATIC_FILES, invalidate_css_cache},
};
use crate::{
CONFIG,
db::{
DbConn,
models::{OrgPolicy, OrgPolicyType, User},
},
};
// Type aliases for API methods results
pub type ApiResult<T> = Result<T, crate::error::Error>;
pub type JsonResult = ApiResult<Json<Value>>;
pub type EmptyResult = ApiResult<()>;
// Common structs representing JSON data received
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PasswordOrOtpData {
#[serde(alias = "MasterPasswordHash")]
master_password_hash: Option<String>,
otp: Option<String>,
}
impl PasswordOrOtpData {
/// Tokens used via this struct can be used multiple times during the process
/// First for the validation to continue, after that to enable or validate the following actions
/// This is different per caller, so it can be adjusted to delete the token or not
pub async fn validate(&self, user: &User, delete_if_valid: bool, conn: &DbConn) -> EmptyResult {
use crate::api::core::two_factor::protected_actions::validate_protected_action_otp;
match (self.master_password_hash.as_deref(), self.otp.as_deref()) {
(Some(pw_hash), None) => {
if !user.check_valid_password(pw_hash) {
err!("Invalid password");
}
}
(None, Some(otp)) => {
validate_protected_action_otp(otp, &user.uuid, delete_if_valid, conn).await?;
}
_ => err!("No validation provided"),
}
Ok(())
}
}
#[expect(clippy::struct_excessive_bools, reason = "Bitwarden clients expect the data in this specific format")]
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct MasterPasswordPolicy {
min_complexity: Option<u8>,
min_length: Option<u32>,
require_lower: bool,
require_upper: bool,
require_numbers: bool,
require_special: bool,
enforce_on_login: bool,
}
// Fetch all valid Master Password Policies and merge them into one with all trues and largest numbers as one policy
async fn master_password_policy(user: &User, conn: &DbConn) -> Value {
let master_password_policies: Vec<MasterPasswordPolicy> =
OrgPolicy::find_accepted_and_confirmed_by_user_and_active_policy(
&user.uuid,
OrgPolicyType::MasterPassword,
conn,
)
.await
.into_iter()
.filter_map(|p| serde_json::from_str(&p.data).ok())
.collect();
let mut mpp_json = if !master_password_policies.is_empty() {
json!(master_password_policies.into_iter().reduce(|acc, policy| {
MasterPasswordPolicy {
min_complexity: acc.min_complexity.max(policy.min_complexity),
min_length: acc.min_length.max(policy.min_length),
require_lower: acc.require_lower || policy.require_lower,
require_upper: acc.require_upper || policy.require_upper,
require_numbers: acc.require_numbers || policy.require_numbers,
require_special: acc.require_special || policy.require_special,
enforce_on_login: acc.enforce_on_login || policy.enforce_on_login,
}
}))
} else if CONFIG.sso_enabled() {
CONFIG.sso_master_password_policy_value().unwrap_or(json!({}))
} else {
json!({})
};
// NOTE: Upstream still uses PascalCase here for `Object`!
mpp_json["Object"] = json!("masterPasswordPolicy");
mpp_json
}
// Identity responses use PascalCase keys like upstream, some clients (Android) only read those
async fn identity_master_password_policy(user: &User, conn: &DbConn) -> Value {
match master_password_policy(user, conn).await {
Value::Object(policy) => Value::Object(
policy
.into_iter()
.map(|(mut key, value)| {
if let Some(first) = key.get_mut(..1) {
first.make_ascii_uppercase();
}
(key, value)
})
.collect(),
),
policy => policy,
}
}