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 @@ -16,7 +16,7 @@ use crate::database::{ DBDeviceTypeInt, DatabaseClient, DeviceType, KeyPayload, }; -use crate::error::Error as DBError; +use crate::error::{DeviceListError, Error as DBError}; use crate::grpc_services::protos::unauth::{ AddReservedUsernamesRequest, Empty, GenerateNonceResponse, OpaqueLoginFinishRequest, OpaqueLoginFinishResponse, OpaqueLoginStartRequest, @@ -748,6 +748,9 @@ | DBError::AwsSdk(DynamoDBError::RequestLimitExceeded(_)) => { tonic::Status::unavailable("please retry") } + DBError::DeviceList(DeviceListError::InvalidDeviceListUpdate) => { + tonic::Status::invalid_argument("invalid device list update") + } e => { error!("Encountered an unexpected error: {}", e); tonic::Status::failed_precondition("unexpected error") 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 @@ -57,7 +57,7 @@ pub use grpc_clients::identity::DeviceType; mod device_list; -pub use device_list::{DeviceListRow, DeviceRow}; +pub use device_list::{DeviceListRow, DeviceListUpdate, DeviceRow}; use self::device_list::Prekey; diff --git a/services/identity/src/database/device_list.rs b/services/identity/src/database/device_list.rs --- a/services/identity/src/database/device_list.rs +++ b/services/identity/src/database/device_list.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use comm_lib::{ @@ -14,7 +14,7 @@ DynamoDBError, TryFromAttribute, }, }; -use tracing::{error, warn}; +use tracing::{debug, error, warn}; use crate::{ client_service::FlattenedDeviceKeyUpload, @@ -64,6 +64,14 @@ pub prekey_signature: String, } +/// A struct representing device list update request +/// payload; issued by the primary device +#[derive(derive_more::Constructor)] +pub struct DeviceListUpdate { + pub devices: Vec, + pub timestamp: DateTime, +} + impl DeviceRow { pub fn from_device_key_upload( user_id: impl Into, @@ -718,6 +726,39 @@ Ok(()) } + /// applies updated device list received from primary device + pub async fn apply_devicelist_update( + &self, + user_id: &str, + update: DeviceListUpdate, + ) -> Result { + let DeviceListUpdate { + devices: new_list, .. + } = update; + self + .transact_update_devicelist(user_id, |current_list, _| { + // TODO: Add proper validation according to the whitepaper + // currently only adding new device is supported (new.len - old.len = 1) + + let new_set: HashSet<_> = new_list.iter().collect(); + let current_set: HashSet<_> = current_list.iter().collect(); + // difference is A - B (only new devices) + let difference: HashSet<_> = new_set.difference(¤t_set).collect(); + if difference.len() != 1 { + warn!("Received invalid device list update"); + return Err(Error::DeviceList( + DeviceListError::InvalidDeviceListUpdate, + )); + } + + debug!("Applying device list update. Difference: {:?}", difference); + *current_list = new_list; + + Ok(None) + }) + .await + } + /// Performs a transactional update of the device list for the user. Afterwards /// generates a new device list and updates the timestamp in the users table. /// This is done in a transaction. Operation fails if the device list has been diff --git a/services/identity/src/error.rs b/services/identity/src/error.rs --- a/services/identity/src/error.rs +++ b/services/identity/src/error.rs @@ -27,6 +27,7 @@ DeviceAlreadyExists, DeviceNotFound, ConcurrentUpdateError, + InvalidDeviceListUpdate, } pub fn consume_error(result: Result) { diff --git a/services/identity/src/grpc_services/authenticated.rs b/services/identity/src/grpc_services/authenticated.rs --- a/services/identity/src/grpc_services/authenticated.rs +++ b/services/identity/src/grpc_services/authenticated.rs @@ -1,8 +1,7 @@ use std::collections::HashMap; use crate::config::CONFIG; -use crate::database::DeviceListRow; -use crate::ddb_utils::Identifier; +use crate::database::{DeviceListRow, DeviceListUpdate}; use crate::{ client_service::{ handle_db_error, CacheExt, UpdateState, WorkflowInProgress, @@ -17,7 +16,7 @@ use comm_opaque2::grpc::protocol_error_to_grpc_status; use moka::future::Cache; use tonic::{Request, Response, Status}; -use tracing::{debug, error}; +use tracing::{debug, error, warn}; use super::protos::auth::{ find_user_id_request, identity, @@ -416,9 +415,21 @@ async fn update_device_list_for_user( &self, - _request: tonic::Request, + request: tonic::Request, ) -> Result, tonic::Status> { - Err(tonic::Status::unimplemented("not implemented")) + let (user_id, _device_id) = get_user_and_device_id(&request)?; + // TODO: when we stop doing "primary device rotation" (migration procedure) + // we should verify if this RPC is called by primary device only + + let new_list = SignedDeviceList::try_from(request.into_inner())?; + let update = DeviceListUpdate::try_from(new_list)?; + self + .db_client + .apply_devicelist_update(&user_id, update) + .await + .map_err(handle_db_error)?; + + Ok(Response::new(Empty {})) } } @@ -457,18 +468,44 @@ raw_device_list: stringified_list, }) } + + fn as_raw(&self) -> Result { + // The device list payload is sent as an escaped JSON payload. + // Escaped double quotes need to be trimmed before attempting to deserialize + serde_json::from_str(&self.raw_device_list.replace(r#"\""#, r#"""#)) + .map_err(|err| { + warn!("Failed to deserialize raw device list: {}", err); + tonic::Status::invalid_argument("invalid device list payload") + }) + } } impl TryFrom for SignedDeviceList { type Error = tonic::Status; fn try_from(request: UpdateDeviceListRequest) -> Result { serde_json::from_str(&request.new_device_list).map_err(|err| { - error!("Failed to deserialize device list update: {}", err); - tonic::Status::failed_precondition("unexpected error") + warn!("Failed to deserialize device list update: {}", err); + tonic::Status::invalid_argument("invalid device list payload") }) } } +impl TryFrom for DeviceListUpdate { + type Error = tonic::Status; + fn try_from(signed_list: SignedDeviceList) -> Result { + let RawDeviceList { + devices, + timestamp: raw_timestamp, + } = signed_list.as_raw()?; + let timestamp = DateTime::::from_utc_timestamp_millis(raw_timestamp) + .ok_or_else(|| { + error!("Failed to parse RawDeviceList timestamp!"); + tonic::Status::invalid_argument("invalid timestamp") + })?; + Ok(DeviceListUpdate::new(devices, timestamp)) + } +} + #[cfg(test)] mod tests { use super::*; @@ -511,4 +548,26 @@ assert_eq!(stringified_updates[0], expected_stringified_list1); assert_eq!(stringified_updates[1], expected_stringified_list2); } + + #[test] + fn deserialize_device_list_update() { + let raw_payload = r#"{"rawDeviceList":"{\"devices\":[\"device1\",\"device2\"],\"timestamp\":123456789}"}"#; + let request = UpdateDeviceListRequest { + new_device_list: raw_payload.to_string(), + }; + + let signed_list = SignedDeviceList::try_from(request) + .expect("Failed to parse SignedDeviceList"); + let update = DeviceListUpdate::try_from(signed_list) + .expect("Failed to parse DeviceListUpdate from signed list"); + + let expected_timestamp = + DateTime::::from_utc_timestamp_millis(123456789).unwrap(); + + assert_eq!(update.timestamp, expected_timestamp); + assert_eq!( + update.devices, + vec!["device1".to_string(), "device2".to_string()] + ); + } }