Browse Source

User key id (#7693)

Co-authored-by: Timshel <timshel@users.noreply.github.com>
pull/7566/merge
Timshel 6 days ago
committed by GitHub
parent
commit
9c8aa2359f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 0
      migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql
  2. 1
      migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql
  3. 0
      migrations/mysql/2026-09-02-120000_add_key_id/down.sql
  4. 1
      migrations/mysql/2026-09-02-120000_add_key_id/up.sql
  5. 0
      migrations/postgresql/2026-09-02-120000_add_key_id/down.sql
  6. 1
      migrations/postgresql/2026-09-02-120000_add_key_id/up.sql
  7. 0
      migrations/sqlite/2026-09-02-120000_add_key_id/down.sql
  8. 1
      migrations/sqlite/2026-09-02-120000_add_key_id/up.sql
  9. 23
      src/api/core/accounts.rs
  10. 25
      src/api/core/ciphers.rs
  11. 2
      src/db/models/mod.rs
  12. 24
      src/db/models/user.rs
  13. 1
      src/db/schema.rs

0
migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql

1
migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;

0
migrations/mysql/2026-09-02-120000_add_key_id/down.sql

1
migrations/mysql/2026-09-02-120000_add_key_id/up.sql

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;

0
migrations/postgresql/2026-09-02-120000_add_key_id/down.sql

1
migrations/postgresql/2026-09-02-120000_add_key_id/up.sql

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;

0
migrations/sqlite/2026-09-02-120000_add_key_id/down.sql

1
migrations/sqlite/2026-09-02-120000_add_key_id/up.sql

@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN key_id TEXT;

23
src/api/core/accounts.rs

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

25
src/api/core/ciphers.rs

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

2
src/db/models/mod.rs

@ -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
src/db/models/user.rs

@ -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
src/db/schema.rs

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

Loading…
Cancel
Save