diff --git a/keyserver/addons/rust-node-addon/src/identity_client/login.rs b/keyserver/addons/rust-node-addon/src/identity_client/login.rs --- a/keyserver/addons/rust-node-addon/src/identity_client/login.rs +++ b/keyserver/addons/rust-node-addon/src/identity_client/login.rs @@ -51,6 +51,7 @@ one_time_notif_prekeys: notif_one_time_keys, device_type: DeviceType::Keyserver.into(), }), + force: None, }; debug!("Starting login to identity service"); diff --git a/native/native_rust_library/src/lib.rs b/native/native_rust_library/src/lib.rs --- a/native/native_rust_library/src/lib.rs +++ b/native/native_rust_library/src/lib.rs @@ -701,6 +701,7 @@ one_time_notif_prekeys: password_user_info.notif_one_time_keys, device_type: DEVICE_TYPE.into(), }), + force: None, }; let mut identity_client = get_unauthenticated_client( diff --git a/services/commtest/src/identity/device.rs b/services/commtest/src/identity/device.rs --- a/services/commtest/src/identity/device.rs +++ b/services/commtest/src/identity/device.rs @@ -24,8 +24,8 @@ pub access_token: String, } -/// Log in existing user with a device. -/// - Tries to log in with given username (it has to be already registered) +/// Register a new user with a device. +/// - Gives random username (returned by function). /// - Device type defaults to keyserver. /// - Device ID taken from `keys` (ed25519), see [`DEFAULT_CLIENT_KEYS`] pub async fn register_user_device( @@ -111,14 +111,15 @@ } } -/// Register a new user with a device. -/// - Gives random username (returned by function). +/// Log in existing user with a device. +/// - Tries to log in with given username (it has to be already registered) /// - Device type defaults to keyserver. /// - Device ID taken from `keys` (ed25519), see [`DEFAULT_CLIENT_KEYS`] pub async fn login_user_device( username: &str, keys: Option<&ClientPublicKeys>, device_type: Option, + force: bool, ) -> DeviceInfo { // TODO: Generate dynamic valid olm account info let keys = keys.unwrap_or_else(|| &DEFAULT_CLIENT_KEYS); @@ -151,6 +152,7 @@ one_time_notif_prekeys: Vec::new(), device_type: device_type.into(), }), + force: Some(force), }; let mut identity_client = get_unauthenticated_client( diff --git a/services/commtest/tests/identity_device_list_tests.rs b/services/commtest/tests/identity_device_list_tests.rs --- a/services/commtest/tests/identity_device_list_tests.rs +++ b/services/commtest/tests/identity_device_list_tests.rs @@ -11,7 +11,7 @@ use grpc_clients::identity::protos::authenticated::GetDeviceListRequest; use grpc_clients::identity::DeviceType; use serde::Deserialize; -use serde_json::{from_str, json}; +use serde_json::json; // 1. register user with android device // 2. register a web device @@ -56,17 +56,25 @@ let username = android.username.clone(); // 2. Log in a web device - let _web = - login_user_device(&username, Some(&DEVICE_KEYS_WEB), Some(DeviceType::Web)) - .await; + let _web = login_user_device( + &username, + Some(&DEVICE_KEYS_WEB), + Some(DeviceType::Web), + false, + ) + .await; // 3. Remove android device logout_user_device(android).await; // 4. Log in an iOS device - let _ios = - login_user_device(&username, Some(&DEVICE_KEYS_IOS), Some(DeviceType::Ios)) - .await; + let _ios = login_user_device( + &username, + Some(&DEVICE_KEYS_IOS), + Some(DeviceType::Ios), + false, + ) + .await; // Get device list updates for the user let device_lists_response: Vec> = @@ -144,6 +152,77 @@ ); } +#[tokio::test] +async fn test_keyserver_force_login() { + use commtest::identity::olm_account_infos::{ + DEFAULT_CLIENT_KEYS as DEVICE_KEYS_ANDROID, + MOCK_CLIENT_KEYS_1 as DEVICE_KEYS_KEYSERVER_1, + MOCK_CLIENT_KEYS_2 as DEVICE_KEYS_KEYSERVER_2, + }; + + // Create viewer (user that doesn't change devices) + let viewer = register_user_device(None, None).await; + let mut auth_client = get_auth_client( + &service_addr::IDENTITY_GRPC.to_string(), + viewer.user_id.clone(), + viewer.device_id, + viewer.access_token, + PLACEHOLDER_CODE_VERSION, + DEVICE_TYPE.to_string(), + ) + .await + .expect("Couldn't connect to identity service"); + + let android_device_id = + &DEVICE_KEYS_ANDROID.primary_identity_public_keys.ed25519; + let keyserver_1_device_id = + &DEVICE_KEYS_KEYSERVER_1.primary_identity_public_keys.ed25519; + let keyserver_2_device_id = + &DEVICE_KEYS_KEYSERVER_2.primary_identity_public_keys.ed25519; + + // 1. Register user with primary Android device + let android = + register_user_device(Some(&DEVICE_KEYS_ANDROID), Some(DeviceType::Android)) + .await; + let user_id = android.user_id.clone(); + let username = android.username.clone(); + + // 2. Log in on keyserver 1 + let _keyserver_1 = login_user_device( + &username, + Some(&DEVICE_KEYS_KEYSERVER_1), + Some(DeviceType::Keyserver), + false, + ) + .await; + + // 3. Log in on keyserver 2 with force = true + let _keyserver_2 = login_user_device( + &username, + Some(&DEVICE_KEYS_KEYSERVER_2), + Some(DeviceType::Keyserver), + true, + ) + .await; + + // Get device list updates for the user + let device_lists_response: Vec> = + get_device_list_history(&mut auth_client, &user_id) + .await + .into_iter() + .map(|device_list| device_list.devices) + .collect(); + + let expected_device_list: Vec> = vec![ + vec![android_device_id.into()], + vec![android_device_id.into(), keyserver_1_device_id.into()], + vec![android_device_id.into()], + vec![android_device_id.into(), keyserver_2_device_id.into()], + ]; + + assert_eq!(device_lists_response, expected_device_list); +} + // See GetDeviceListResponse in identity_authenticated.proto // for details on the response format. #[derive(Deserialize)] diff --git a/services/identity/src/client_service.rs b/services/identity/src/client_service.rs --- a/services/identity/src/client_service.rs +++ b/services/identity/src/client_service.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use siwe::eip55; use tonic::Response; -use tracing::{debug, error, warn}; +use tracing::{debug, error, info, warn}; // Workspace crate imports use crate::config::CONFIG; @@ -66,6 +66,7 @@ pub user_id: String, pub flattened_device_key_upload: FlattenedDeviceKeyUpload, pub opaque_server_login: comm_opaque2::server::Login, + pub device_to_remove: Option, } #[derive(Clone, Serialize, Deserialize)] @@ -265,7 +266,7 @@ ) -> Result, tonic::Status> { let message = request.into_inner(); - debug!("Attempting to login user: {:?}", &message.username); + debug!("Attempting to log in user: {:?}", &message.username); let user_id_and_password_file = self .client .get_user_id_and_password_file_from_username(&message.username) @@ -296,6 +297,18 @@ return Err(tonic::Status::not_found("user not found")); }; + let flattened_device_key_upload = + construct_flattened_device_key_upload(&message)?; + + let maybe_device_to_remove = self + .get_keyserver_device_to_remove( + &user_id, + &flattened_device_key_upload.device_id_key, + message.force.unwrap_or(false), + &flattened_device_key_upload.device_type, + ) + .await?; + let mut server_login = comm_opaque2::server::Login::new(); let server_response = server_login .start( @@ -306,8 +319,12 @@ ) .map_err(protocol_error_to_grpc_status)?; - let login_state = - construct_user_login_info(&message, user_id, server_login)?; + let login_state = construct_user_login_info( + user_id, + server_login, + flattened_device_key_upload, + maybe_device_to_remove, + )?; let session_id = self .client @@ -340,6 +357,14 @@ .finish(&message.opaque_login_upload) .map_err(protocol_error_to_grpc_status)?; + if let Some(device_to_remove) = state.device_to_remove { + self + .client + .remove_device(state.user_id.clone(), device_to_remove) + .await + .map_err(handle_db_error)?; + } + let login_time = chrono::Utc::now(); self .client @@ -896,6 +921,44 @@ }; Ok(()) } + + async fn get_keyserver_device_to_remove( + &self, + user_id: &str, + new_keyserver_device_id: &str, + force: bool, + device_type: &DeviceType, + ) -> Result, tonic::Status> { + if device_type != &DeviceType::Keyserver { + return Ok(None); + } + + let maybe_keyserver_device_id = self + .client + .get_keyserver_device_id_for_user(user_id) + .await + .map_err(handle_db_error)?; + + let Some(existing_keyserver_device_id) = maybe_keyserver_device_id else { + return Ok(None); + }; + + if new_keyserver_device_id == existing_keyserver_device_id { + return Ok(None); + } + + if force { + info!( + "keyserver {} will be removed from the device list", + existing_keyserver_device_id + ); + Ok(Some(existing_keyserver_device_id)) + } else { + Err(tonic::Status::already_exists( + "user already has a keyserver", + )) + } + } } pub fn handle_db_error(db_error: DBError) -> tonic::Status { @@ -932,16 +995,16 @@ } fn construct_user_login_info( - message: &impl DeviceKeyUploadActions, user_id: String, opaque_server_login: comm_opaque2::server::Login, + flattened_device_key_upload: FlattenedDeviceKeyUpload, + device_to_remove: Option, ) -> Result { Ok(UserLoginInfo { user_id, - flattened_device_key_upload: construct_flattened_device_key_upload( - message, - )?, + flattened_device_key_upload, opaque_server_login, + device_to_remove, }) } diff --git a/services/identity/src/database.rs b/services/identity/src/database.rs --- a/services/identity/src/database.rs +++ b/services/identity/src/database.rs @@ -390,6 +390,21 @@ Ok(Some(outbound_payload)) } + pub async fn get_keyserver_device_id_for_user( + &self, + user_id: &str, + ) -> Result, Error> { + use crate::grpc_services::protos::unauth::DeviceType as GrpcDeviceType; + + let user_devices = self.get_current_devices(user_id).await?; + let maybe_keyserver_device_id = user_devices + .into_iter() + .find(|device| device.device_type == GrpcDeviceType::Keyserver) + .map(|device| device.device_id); + + Ok(maybe_keyserver_device_id) + } + /// Will "mint" a single one-time key by attempting to successfully delete a /// key pub async fn get_one_time_key( diff --git a/shared/protos/identity_unauth.proto b/shared/protos/identity_unauth.proto --- a/shared/protos/identity_unauth.proto +++ b/shared/protos/identity_unauth.proto @@ -164,6 +164,10 @@ // Information specific to a user's device needed to open a new channel of // communication with this user DeviceKeyUpload device_key_upload = 3; + // If set to true, the user's existing keyserver will be deleted from the + // identity service and replaced with this one. This field has no effect if + // the device is not a keyserver + optional bool force = 4; } message OpaqueLoginFinishRequest { diff --git a/web/protobufs/identity-unauth-structs.cjs b/web/protobufs/identity-unauth-structs.cjs --- a/web/protobufs/identity-unauth-structs.cjs +++ b/web/protobufs/identity-unauth-structs.cjs @@ -2457,7 +2457,8 @@ var f, obj = { username: jspb.Message.getFieldWithDefault(msg, 1, ""), opaqueLoginRequest: msg.getOpaqueLoginRequest_asB64(), - deviceKeyUpload: (f = msg.getDeviceKeyUpload()) && proto.identity.unauth.DeviceKeyUpload.toObject(includeInstance, f) + deviceKeyUpload: (f = msg.getDeviceKeyUpload()) && proto.identity.unauth.DeviceKeyUpload.toObject(includeInstance, f), + force: jspb.Message.getBooleanFieldWithDefault(msg, 4, false) }; if (includeInstance) { @@ -2507,6 +2508,10 @@ reader.readMessage(value,proto.identity.unauth.DeviceKeyUpload.deserializeBinaryFromReader); msg.setDeviceKeyUpload(value); break; + case 4: + var value = /** @type {boolean} */ (reader.readBool()); + msg.setForce(value); + break; default: reader.skipField(); break; @@ -2558,6 +2563,13 @@ proto.identity.unauth.DeviceKeyUpload.serializeBinaryToWriter ); } + f = /** @type {boolean} */ (jspb.Message.getField(message, 4)); + if (f != null) { + writer.writeBool( + 4, + f + ); + } }; @@ -2658,6 +2670,42 @@ }; +/** + * optional bool force = 4; + * @return {boolean} + */ +proto.identity.unauth.OpaqueLoginStartRequest.prototype.getForce = function() { + return /** @type {boolean} */ (jspb.Message.getBooleanFieldWithDefault(this, 4, false)); +}; + + +/** + * @param {boolean} value + * @return {!proto.identity.unauth.OpaqueLoginStartRequest} returns this + */ +proto.identity.unauth.OpaqueLoginStartRequest.prototype.setForce = function(value) { + return jspb.Message.setField(this, 4, value); +}; + + +/** + * Clears the field making it undefined. + * @return {!proto.identity.unauth.OpaqueLoginStartRequest} returns this + */ +proto.identity.unauth.OpaqueLoginStartRequest.prototype.clearForce = function() { + return jspb.Message.setField(this, 4, undefined); +}; + + +/** + * Returns whether this field is set. + * @return {boolean} + */ +proto.identity.unauth.OpaqueLoginStartRequest.prototype.hasForce = function() { + return jspb.Message.getField(this, 4) != null; +}; + + diff --git a/web/protobufs/identity-unauth-structs.cjs.flow b/web/protobufs/identity-unauth-structs.cjs.flow --- a/web/protobufs/identity-unauth-structs.cjs.flow +++ b/web/protobufs/identity-unauth-structs.cjs.flow @@ -250,6 +250,11 @@ hasDeviceKeyUpload(): boolean; clearDeviceKeyUpload(): OpaqueLoginStartRequest; + getForce(): boolean; + setForce(value: boolean): OpaqueLoginStartRequest; + hasForce(): boolean; + clearForce(): OpaqueLoginStartRequest; + serializeBinary(): Uint8Array; toObject(includeInstance?: boolean): OpaqueLoginStartRequestObject; static toObject(includeInstance: boolean, msg: OpaqueLoginStartRequest): OpaqueLoginStartRequestObject; @@ -262,6 +267,7 @@ username: string, opaqueLoginRequest: Uint8Array | string, deviceKeyUpload?: DeviceKeyUploadObject, + force?: boolean, }; declare export class OpaqueLoginFinishRequest extends Message {