User key id (#7693)

Co-authored-by: Timshel <timshel@users.noreply.github.com>
This commit is contained in:
TimshelandTimshel authored and GitHub committed 2026-09-18 16:53:06 +02:00
1 parent 8719f86fcf
commit 9c8aa2359f
13 files changed
+74 -5

No files matched your search

Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
Whitespace-only changes.
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;
+21 -2
View File
@@ -21,8 +21,9 @@ use crate::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest,
EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership,
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId,
UserKdfType,
}, },
}, },
mail, mail,
@@ -46,6 +47,7 @@ pub fn routes() -> Vec<rocket::Route> {
post_set_password, post_set_password,
post_kdf, post_kdf,
post_rotatekey, post_rotatekey,
post_user_key,
post_sstamp, post_sstamp,
post_email_token, post_email_token,
post_email, post_email,
@@ -1025,6 +1027,23 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
save_result save_result
} }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeyIdData {
user_key_id: KeyId,
}
#[post("/accounts/key-management/user-key-id", data = "<data>")]
async fn post_user_key(data: Json<KeyIdData>, headers: Headers, conn: DbConn) -> EmptyResult {
let mut user = headers.user;
if user.key_id.is_some() {
err_code!("Unexpected data", Status::UnprocessableEntity.code);
}
user.key_id = Some(data.into_inner().user_key_id);
user.save(&conn).await
}
#[post("/accounts/security-stamp", data = "<data>")] #[post("/accounts/security-stamp", data = "<data>")]
async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { async fn post_sstamp(data: Json<PasswordOrOtpData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult {
let data: PasswordOrOtpData = data.into_inner(); let data: PasswordOrOtpData = data.into_inner();
+23 -2
View File
@@ -6,6 +6,7 @@ use rocket::{
Route, Route,
form::{Form, FromForm}, form::{Form, FromForm},
fs::TempFile, fs::TempFile,
http::Status,
serde::json::Json, serde::json::Json,
}; };
use serde_json::Value; use serde_json::Value;
@@ -21,8 +22,8 @@ use crate::{
DbConn, DbPool, DbConn, DbPool,
models::{ models::{
Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup,
CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId,
MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId,
}, },
}, },
util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file},
@@ -198,6 +199,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"sends": sends_json, "sends": sends_json,
"userDecryption": { "userDecryption": {
"masterPasswordUnlock": master_password_unlock, "masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
}, },
"object": "sync" "object": "sync"
}))) })))
@@ -260,6 +262,10 @@ pub struct CipherData {
key: Option<String>, key: Option<String>,
pub encrypted_for: UserId, // Added in web-v2025.6.0
// Added in web-v2025.8.1, Optional for compat
pub encrypted_by_key_id: Option<KeyId>,
/* /*
Login = 1, Login = 1,
SecureNote = 2, SecureNote = 2,
@@ -333,6 +339,10 @@ async fn post_ciphers_create(
) -> JsonResult { ) -> JsonResult {
let mut data: ShareCipherData = data.into_inner(); let mut data: ShareCipherData = data.into_inner();
if data.cipher.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
// This check is usually only needed in update_cipher_from_data(), but we // This check is usually only needed in update_cipher_from_data(), but we
// need it here as well to avoid creating an empty cipher in the call to // need it here as well to avoid creating an empty cipher in the call to
// cipher.save() below. // cipher.save() below.
@@ -362,6 +372,17 @@ async fn post_ciphers_create(
async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { async fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult {
let mut data: CipherData = data.into_inner(); let mut data: CipherData = data.into_inner();
if data.encrypted_for != headers.user.uuid {
err_code!("Invalid user cipher", Status::UnprocessableEntity.code);
}
if let Some(cipher_key_id) = &data.encrypted_by_key_id
&& let Some(user_key_id) = &headers.user.key_id
&& cipher_key_id != user_key_id
{
err_code!("Invalid key cipher", Status::UnprocessableEntity.code);
}
// The web/browser clients set this field to null as expected, but the // The web/browser clients set this field to null as expected, but the
// mobile clients seem to set the invalid value `0001-01-01T00:00:00`, // mobile clients seem to set the invalid value `0001-01-01T00:00:00`,
// which results in a warning message being logged. This field isn't // which results in a warning message being logged. This field isn't
+1 -1
View File
@@ -39,4 +39,4 @@ pub use self::sso_auth::{OIDCAuthenticatedUser, OIDCCodeResponseError, SsoAuth};
pub use self::two_factor::{TwoFactor, TwoFactorType}; pub use self::two_factor::{TwoFactor, TwoFactorType};
pub use self::two_factor_duo_context::TwoFactorDuoContext; pub use self::two_factor_duo_context::TwoFactorDuoContext;
pub use self::two_factor_incomplete::TwoFactorIncomplete; pub use self::two_factor_incomplete::TwoFactorIncomplete;
pub use self::user::{Invitation, SsoUser, User, UserId, UserKdfType, UserStampException}; pub use self::user::{Invitation, KeyId, SsoUser, User, UserId, UserKdfType, UserStampException};
+24
View File
@@ -69,6 +69,8 @@ pub struct User {
pub avatar_color: Option<String>, pub avatar_color: Option<String>,
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore. pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub key_id: Option<KeyId>,
} }
#[derive(Identifiable, Queryable, Insertable)] #[derive(Identifiable, Queryable, Insertable)]
@@ -154,6 +156,8 @@ impl User {
avatar_color: None, avatar_color: None,
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. external_id: None, // Todo: Needs to be removed in the future, this is not used anymore.
key_id: None,
} }
} }
@@ -527,6 +531,26 @@ impl Invitation {
#[from(forward)] #[from(forward)]
pub struct UserId(String); pub struct UserId(String);
#[derive(
Clone,
Debug,
DieselNewType,
FromForm,
PartialEq,
Eq,
Hash,
Serialize,
Deserialize,
AsRef,
Deref,
Display,
From,
UuidFromParam,
)]
#[deref(forward)]
#[from(forward)]
pub struct KeyId(String);
impl SsoUser { impl SsoUser {
pub async fn save(&self, conn: &DbConn) -> EmptyResult { pub async fn save(&self, conn: &DbConn) -> EmptyResult {
db_run! { conn: db_run! { conn:
+1
View File
@@ -217,6 +217,7 @@ table! {
api_key -> Nullable<Text>, api_key -> Nullable<Text>,
avatar_color -> Nullable<Text>, avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
key_id -> Nullable<Text>,
} }
} }