diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql b/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql new file mode 100644 index 00000000..d25396cf --- /dev/null +++ b/migrations/cockroachdb/2026-09-02-120000_add_key_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/mysql/2026-09-02-120000_add_key_id/down.sql b/migrations/mysql/2026-09-02-120000_add_key_id/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/mysql/2026-09-02-120000_add_key_id/up.sql b/migrations/mysql/2026-09-02-120000_add_key_id/up.sql new file mode 100644 index 00000000..d25396cf --- /dev/null +++ b/migrations/mysql/2026-09-02-120000_add_key_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/postgresql/2026-09-02-120000_add_key_id/down.sql b/migrations/postgresql/2026-09-02-120000_add_key_id/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql b/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql new file mode 100644 index 00000000..d25396cf --- /dev/null +++ b/migrations/postgresql/2026-09-02-120000_add_key_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/migrations/sqlite/2026-09-02-120000_add_key_id/down.sql b/migrations/sqlite/2026-09-02-120000_add_key_id/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql b/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql new file mode 100644 index 00000000..d25396cf --- /dev/null +++ b/migrations/sqlite/2026-09-02-120000_add_key_id/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN key_id TEXT; diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 69be1334..8cc5e55b 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -21,8 +21,9 @@ use crate::{ DbConn, DbPool, models::{ AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, DeviceWithAuthRequest, - EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, - OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType, + EmergencyAccess, EmergencyAccessId, EventType, Folder, FolderId, Invitation, KeyId, Membership, + MembershipId, OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, + UserKdfType, }, }, mail, @@ -46,6 +47,7 @@ pub fn routes() -> Vec { post_set_password, post_kdf, post_rotatekey, + post_user_key, post_sstamp, post_email_token, post_email, @@ -1025,6 +1027,23 @@ async fn post_rotatekey(data: Json, headers: Headers, conn: DbConn, nt: save_result } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct KeyIdData { + user_key_id: KeyId, +} + +#[post("/accounts/key-management/user-key-id", data = "")] +async fn post_user_key(data: Json, 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 = "")] async fn post_sstamp(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> EmptyResult { let data: PasswordOrOtpData = data.into_inner(); diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index 50be6732..d8081cba 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -6,6 +6,7 @@ use rocket::{ Route, form::{Form, FromForm}, fs::TempFile, + http::Status, serde::json::Json, }; use serde_json::Value; @@ -21,8 +22,8 @@ use crate::{ DbConn, DbPool, models::{ Archive, Attachment, AttachmentId, Cipher, CipherId, Collection, CollectionCipher, CollectionGroup, - CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, Membership, - MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, + CollectionId, CollectionUser, EventType, Favorite, Folder, FolderCipher, FolderId, Group, KeyId, + Membership, MembershipType, OrgPolicy, OrgPolicyType, OrganizationId, RepromptType, Send, UserId, }, }, util::{NumberOrString, deser_opt_nonempty_str, save_temp_file}, @@ -198,6 +199,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option, + 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, + /* Login = 1, SecureNote = 2, @@ -333,6 +339,10 @@ async fn post_ciphers_create( ) -> JsonResult { 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 // need it here as well to avoid creating an empty cipher in the call to // cipher.save() below. @@ -362,6 +372,17 @@ async fn post_ciphers_create( async fn post_ciphers(data: Json, headers: Headers, conn: DbConn, nt: Notify<'_>) -> JsonResult { 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 // 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 diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 0ed8ef91..0e4073a5 100644 --- a/src/db/models/mod.rs +++ b/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_duo_context::TwoFactorDuoContext; 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}; diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 81cb8d84..36089d1a 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -69,6 +69,8 @@ pub struct User { pub avatar_color: Option, pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. + + pub key_id: Option, } #[derive(Identifiable, Queryable, Insertable)] @@ -154,6 +156,8 @@ impl User { avatar_color: None, 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)] 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 { pub async fn save(&self, conn: &DbConn) -> EmptyResult { db_run! { conn: diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..98b1eda6 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -217,6 +217,7 @@ table! { api_key -> Nullable, avatar_color -> Nullable, external_id -> Nullable, + key_id -> Nullable, } }