Files
Mathijs van Veluw 8d30285160 Fix issue with key-rotation and emergency-access (#6421)
When a user has an unconfirmed emergency-access user, and tries to do a key-rotation, the validation fails.
The reason is that Bitwarden only returns new keys for confirmed users, not for invited or accepted.

This commit fixes this by only requesting confirmed or higher status emergency-access users.

Signed-off-by: BlackDex <black.dex@gmail.com>
2025-11-01 22:20:38 +01:00

388 lines
13 KiB
Rust

use chrono::{NaiveDateTime, Utc};
use derive_more::{AsRef, Deref, Display, From};
use serde_json::Value;
use super::{User, UserId};
use crate::db::schema::emergency_access;
use crate::{api::EmptyResult, db::DbConn, error::MapResult};
use diesel::prelude::*;
use macros::UuidFromParam;
#[derive(Identifiable, Queryable, Insertable, AsChangeset)]
#[diesel(table_name = emergency_access)]
#[diesel(treat_none_as_null = true)]
#[diesel(primary_key(uuid))]
pub struct EmergencyAccess {
pub uuid: EmergencyAccessId,
pub grantor_uuid: UserId,
pub grantee_uuid: Option<UserId>,
pub email: Option<String>,
pub key_encrypted: Option<String>,
pub atype: i32, //EmergencyAccessType
pub status: i32, //EmergencyAccessStatus
pub wait_time_days: i32,
pub recovery_initiated_at: Option<NaiveDateTime>,
pub last_notification_at: Option<NaiveDateTime>,
pub updated_at: NaiveDateTime,
pub created_at: NaiveDateTime,
}
// Local methods
impl EmergencyAccess {
pub fn new(grantor_uuid: UserId, email: String, status: i32, atype: i32, wait_time_days: i32) -> Self {
let now = Utc::now().naive_utc();
Self {
uuid: EmergencyAccessId(crate::util::get_uuid()),
grantor_uuid,
grantee_uuid: None,
email: Some(email),
status,
atype,
wait_time_days,
recovery_initiated_at: None,
created_at: now,
updated_at: now,
key_encrypted: None,
last_notification_at: None,
}
}
pub fn get_type_as_str(&self) -> &'static str {
if self.atype == EmergencyAccessType::View as i32 {
"View"
} else {
"Takeover"
}
}
pub fn to_json(&self) -> Value {
json!({
"id": self.uuid,
"status": self.status,
"type": self.atype,
"waitTimeDays": self.wait_time_days,
"object": "emergencyAccess",
})
}
pub async fn to_json_grantor_details(&self, conn: &DbConn) -> Value {
let grantor_user = User::find_by_uuid(&self.grantor_uuid, conn).await.expect("Grantor user not found.");
json!({
"id": self.uuid,
"status": self.status,
"type": self.atype,
"waitTimeDays": self.wait_time_days,
"grantorId": grantor_user.uuid,
"email": grantor_user.email,
"name": grantor_user.name,
"avatarColor": grantor_user.avatar_color,
"object": "emergencyAccessGrantorDetails",
})
}
pub async fn to_json_grantee_details(&self, conn: &DbConn) -> Option<Value> {
let grantee_user = if let Some(grantee_uuid) = &self.grantee_uuid {
User::find_by_uuid(grantee_uuid, conn).await.expect("Grantee user not found.")
} else if let Some(email) = self.email.as_deref() {
match User::find_by_mail(email, conn).await {
Some(user) => user,
None => {
// remove outstanding invitations which should not exist
Self::delete_all_by_grantee_email(email, conn).await.ok();
return None;
}
}
} else {
return None;
};
Some(json!({
"id": self.uuid,
"status": self.status,
"type": self.atype,
"waitTimeDays": self.wait_time_days,
"granteeId": grantee_user.uuid,
"email": grantee_user.email,
"name": grantee_user.name,
"avatarColor": grantee_user.avatar_color,
"object": "emergencyAccessGranteeDetails",
}))
}
}
#[derive(Copy, Clone)]
pub enum EmergencyAccessType {
View = 0,
Takeover = 1,
}
impl EmergencyAccessType {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"0" | "View" => Some(EmergencyAccessType::View),
"1" | "Takeover" => Some(EmergencyAccessType::Takeover),
_ => None,
}
}
}
pub enum EmergencyAccessStatus {
Invited = 0,
Accepted = 1,
Confirmed = 2,
RecoveryInitiated = 3,
RecoveryApproved = 4,
}
// region Database methods
impl EmergencyAccess {
pub async fn save(&mut self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.grantor_uuid, conn).await;
self.updated_at = Utc::now().naive_utc();
db_run! { conn:
sqlite, mysql {
match diesel::replace_into(emergency_access::table)
.values(&*self)
.execute(conn)
{
Ok(_) => Ok(()),
// Record already exists and causes a Foreign Key Violation because replace_into() wants to delete the record first.
Err(diesel::result::Error::DatabaseError(diesel::result::DatabaseErrorKind::ForeignKeyViolation, _)) => {
diesel::update(emergency_access::table)
.filter(emergency_access::uuid.eq(&self.uuid))
.set(&*self)
.execute(conn)
.map_res("Error updating emergency access")
}
Err(e) => Err(e.into()),
}.map_res("Error saving emergency access")
}
postgresql {
diesel::insert_into(emergency_access::table)
.values(&*self)
.on_conflict(emergency_access::uuid)
.do_update()
.set(&*self)
.execute(conn)
.map_res("Error saving emergency access")
}
}
}
pub async fn update_access_status_and_save(
&mut self,
status: i32,
date: &NaiveDateTime,
conn: &DbConn,
) -> EmptyResult {
// Update the grantee so that it will refresh it's status.
User::update_uuid_revision(self.grantee_uuid.as_ref().expect("Error getting grantee"), conn).await;
self.status = status;
date.clone_into(&mut self.updated_at);
db_run! { conn: {
crate::util::retry(|| {
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
.set((emergency_access::status.eq(status), emergency_access::updated_at.eq(date)))
.execute(conn)
}, 10)
.map_res("Error updating emergency access status")
}}
}
pub async fn update_last_notification_date_and_save(&mut self, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
self.last_notification_at = Some(date.to_owned());
date.clone_into(&mut self.updated_at);
db_run! { conn: {
crate::util::retry(|| {
diesel::update(emergency_access::table.filter(emergency_access::uuid.eq(&self.uuid)))
.set((emergency_access::last_notification_at.eq(date), emergency_access::updated_at.eq(date)))
.execute(conn)
}, 10)
.map_res("Error updating emergency access status")
}}
}
pub async fn delete_all_by_user(user_uuid: &UserId, conn: &DbConn) -> EmptyResult {
for ea in Self::find_all_by_grantor_uuid(user_uuid, conn).await {
ea.delete(conn).await?;
}
for ea in Self::find_all_by_grantee_uuid(user_uuid, conn).await {
ea.delete(conn).await?;
}
Ok(())
}
pub async fn delete_all_by_grantee_email(grantee_email: &str, conn: &DbConn) -> EmptyResult {
for ea in Self::find_all_invited_by_grantee_email(grantee_email, conn).await {
ea.delete(conn).await?;
}
Ok(())
}
pub async fn delete(self, conn: &DbConn) -> EmptyResult {
User::update_uuid_revision(&self.grantor_uuid, conn).await;
db_run! { conn: {
diesel::delete(emergency_access::table.filter(emergency_access::uuid.eq(self.uuid)))
.execute(conn)
.map_res("Error removing user from emergency access")
}}
}
pub async fn find_by_grantor_uuid_and_grantee_uuid_or_email(
grantor_uuid: &UserId,
grantee_uuid: &UserId,
email: &str,
conn: &DbConn,
) -> Option<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
.filter(emergency_access::grantee_uuid.eq(grantee_uuid).or(emergency_access::email.eq(email)))
.first::<Self>(conn)
.ok()
}}
}
pub async fn find_all_recoveries_initiated(conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::status.eq(EmergencyAccessStatus::RecoveryInitiated as i32))
.filter(emergency_access::recovery_initiated_at.is_not_null())
.load::<Self>(conn)
.expect("Error loading emergency_access")
}}
}
pub async fn find_by_uuid_and_grantor_uuid(
uuid: &EmergencyAccessId,
grantor_uuid: &UserId,
conn: &DbConn,
) -> Option<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::uuid.eq(uuid))
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
.first::<Self>(conn)
.ok()
}}
}
pub async fn find_by_uuid_and_grantee_uuid(
uuid: &EmergencyAccessId,
grantee_uuid: &UserId,
conn: &DbConn,
) -> Option<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::uuid.eq(uuid))
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
.first::<Self>(conn)
.ok()
}}
}
pub async fn find_by_uuid_and_grantee_email(
uuid: &EmergencyAccessId,
grantee_email: &str,
conn: &DbConn,
) -> Option<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::uuid.eq(uuid))
.filter(emergency_access::email.eq(grantee_email))
.first::<Self>(conn)
.ok()
}}
}
pub async fn find_all_by_grantee_uuid(grantee_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::grantee_uuid.eq(grantee_uuid))
.load::<Self>(conn)
.expect("Error loading emergency_access")
}}
}
pub async fn find_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Option<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::email.eq(grantee_email))
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
.first::<Self>(conn)
.ok()
}}
}
pub async fn find_all_invited_by_grantee_email(grantee_email: &str, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::email.eq(grantee_email))
.filter(emergency_access::status.eq(EmergencyAccessStatus::Invited as i32))
.load::<Self>(conn)
.expect("Error loading emergency_access")
}}
}
pub async fn find_all_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
.load::<Self>(conn)
.expect("Error loading emergency_access")
}}
}
pub async fn find_all_confirmed_by_grantor_uuid(grantor_uuid: &UserId, conn: &DbConn) -> Vec<Self> {
db_run! { conn: {
emergency_access::table
.filter(emergency_access::grantor_uuid.eq(grantor_uuid))
.filter(emergency_access::status.ge(EmergencyAccessStatus::Confirmed as i32))
.load::<Self>(conn)
.expect("Error loading emergency_access")
}}
}
pub async fn accept_invite(&mut self, grantee_uuid: &UserId, grantee_email: &str, conn: &DbConn) -> EmptyResult {
if self.email.is_none() || self.email.as_ref().unwrap() != grantee_email {
err!("User email does not match invite.");
}
if self.status == EmergencyAccessStatus::Accepted as i32 {
err!("Emergency contact already accepted.");
}
self.status = EmergencyAccessStatus::Accepted as i32;
self.grantee_uuid = Some(grantee_uuid.clone());
self.email = None;
self.save(conn).await
}
}
// endregion
#[derive(
Clone,
Debug,
AsRef,
Deref,
DieselNewType,
Display,
From,
FromForm,
Hash,
PartialEq,
Eq,
Serialize,
Deserialize,
UuidFromParam,
)]
pub struct EmergencyAccessId(String);