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 @@ -50,6 +50,7 @@ pub use grpc_clients::identity::DeviceType; mod device_list; +pub use device_list::DeviceListRow; #[derive(Serialize, Deserialize)] pub struct OlmKeys { diff --git a/services/identity/src/ddb_utils.rs b/services/identity/src/ddb_utils.rs --- a/services/identity/src/ddb_utils.rs +++ b/services/identity/src/ddb_utils.rs @@ -1,4 +1,5 @@ use aws_sdk_dynamodb::model::{AttributeValue, PutRequest, WriteRequest}; +use chrono::{DateTime, NaiveDateTime, Utc}; use std::collections::HashMap; use std::iter::IntoIterator; @@ -78,3 +79,14 @@ }) } } + +pub trait DateTimeExt { + fn from_utc_timestamp_millis(timestamp: i64) -> Option>; +} + +impl DateTimeExt for DateTime { + fn from_utc_timestamp_millis(timestamp: i64) -> Option { + let naive = NaiveDateTime::from_timestamp_millis(timestamp)?; + Some(Self::from_utc(naive, Utc)) + } +} 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,6 +1,7 @@ use std::collections::HashMap; use crate::config::CONFIG; +use crate::database::DeviceListRow; use crate::grpc_utils::DeviceInfoWithAuth; use crate::{ client_service::{ @@ -8,9 +9,11 @@ }, constants::request_metadata, database::DatabaseClient, + ddb_utils::DateTimeExt, grpc_services::shared::get_value, token::AuthType, }; +use chrono::{DateTime, Utc}; use comm_opaque2::grpc::protocol_error_to_grpc_status; use moka::future::Cache; use tonic::{Request, Response, Status}; @@ -18,9 +21,10 @@ use super::protos::auth::{ find_user_id_request, identity_client_service_server::IdentityClientService, - FindUserIdRequest, FindUserIdResponse, InboundKeyInfo, - InboundKeysForUserRequest, InboundKeysForUserResponse, KeyserverKeysResponse, - OutboundKeyInfo, OutboundKeysForUserRequest, OutboundKeysForUserResponse, + FindUserIdRequest, FindUserIdResponse, GetDeviceListRequest, + GetDeviceListResponse, InboundKeyInfo, InboundKeysForUserRequest, + InboundKeysForUserResponse, KeyserverKeysResponse, OutboundKeyInfo, + OutboundKeysForUserRequest, OutboundKeysForUserResponse, RefreshUserPreKeysRequest, UpdateUserPasswordFinishRequest, UpdateUserPasswordStartRequest, UpdateUserPasswordStartResponse, UploadOneTimeKeysRequest, @@ -375,4 +379,130 @@ let response = Empty {}; Ok(Response::new(response)) } + + async fn get_device_list_for_user( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let GetDeviceListRequest { + user_id, + since_timestamp, + } = request.into_inner(); + + let since = since_timestamp + .map(|timestamp| { + DateTime::::from_utc_timestamp_millis(timestamp) + .ok_or_else(|| tonic::Status::invalid_argument("Invalid timestamp")) + }) + .transpose()?; + + let mut db_result = self + .db_client + .get_device_list_history(user_id, since) + .await + .map_err(handle_db_error)?; + + // these should be sorted already, but just in case + db_result.sort_by_key(|list| list.timestamp); + + let device_list_updates: Vec = db_result + .into_iter() + .map(RawDeviceList::from) + .map(SignedDeviceList::try_from_raw) + .collect::, _>>()?; + + let stringified_updates = device_list_updates + .iter() + .map(serde_json::to_string) + .collect::, _>>() + .map_err(|err| { + error!("Failed to serialize device list updates: {}", err); + tonic::Status::failed_precondition("unexpected error") + })?; + + Ok(Response::new(GetDeviceListResponse { + device_list_updates: stringified_updates, + })) + } +} + +// raw device list that can be serialized to JSON (and then signed in the future) +#[derive(serde::Serialize)] +struct RawDeviceList { + devices: Vec, + timestamp: i64, +} + +impl From for RawDeviceList { + fn from(row: DeviceListRow) -> Self { + Self { + devices: row.device_ids, + timestamp: row.timestamp.timestamp_millis(), + } + } +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SignedDeviceList { + /// JSON-stringified [`RawDeviceList`] + raw_device_list: String, +} + +impl SignedDeviceList { + /// Serialize (and sign in the future) a [`RawDeviceList`] + fn try_from_raw(raw: RawDeviceList) -> Result { + let stringified_list = serde_json::to_string(&raw).map_err(|err| { + error!("Failed to serialize raw device list: {}", err); + tonic::Status::failed_precondition("unexpected error") + })?; + + Ok(Self { + raw_device_list: stringified_list, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serialize_device_list_updates() { + let raw_updates = vec![ + RawDeviceList { + devices: vec!["device1".into()], + timestamp: 111111111, + }, + RawDeviceList { + devices: vec!["device1".into(), "device2".into()], + timestamp: 222222222, + }, + ]; + + let expected_raw_list1 = r#"{"devices":["device1"],"timestamp":111111111}"#; + let expected_raw_list2 = + r#"{"devices":["device1","device2"],"timestamp":222222222}"#; + + let signed_updates = raw_updates + .into_iter() + .map(SignedDeviceList::try_from_raw) + .collect::, _>>() + .expect("signing device list updates failed"); + + assert_eq!(signed_updates[0].raw_device_list, expected_raw_list1); + assert_eq!(signed_updates[1].raw_device_list, expected_raw_list2); + + let stringified_updates = signed_updates + .iter() + .map(serde_json::to_string) + .collect::, _>>() + .expect("serialize signed device lists failed"); + + let expected_stringified_list1 = r#"{"rawDeviceList":"{\"devices\":[\"device1\"],\"timestamp\":111111111}"}"#; + let expected_stringified_list2 = r#"{"rawDeviceList":"{\"devices\":[\"device1\",\"device2\"],\"timestamp\":222222222}"}"#; + + assert_eq!(stringified_updates[0], expected_stringified_list1); + assert_eq!(stringified_updates[1], expected_stringified_list2); + } } diff --git a/shared/protos/identity_authenticated.proto b/shared/protos/identity_authenticated.proto --- a/shared/protos/identity_authenticated.proto +++ b/shared/protos/identity_authenticated.proto @@ -52,6 +52,10 @@ // Returns userID for given username or wallet address rpc FindUserID(FindUserIDRequest) returns (FindUserIDResponse) {} + + // Returns device list history + rpc GetDeviceListForUser(GetDeviceListRequest) returns + (GetDeviceListResponse) {} } // Helper types @@ -157,3 +161,24 @@ string sessionID = 1; bytes opaqueRegistrationResponse = 2; } + +// GetDeviceListForUser + +message GetDeviceListRequest { + // User whose device lists we want to retrieve + string user_id = 1; + // UTC timestamp in milliseconds + // If none, whole device list history will be retrieved + optional int64 since_timestamp = 2; +} + +message GetDeviceListResponse { + // A list of stringified JSON objects of the following format: + // { + // "rawDeviceList": JSON.stringify({ + // "devices": [, ...] + // "timestamp": , + // }) + // } + repeated string device_list_updates = 1; +}