diff --git a/services/identity/src/database.rs b/services/identity/src/database.rs index e95532911..31550a782 100644 --- a/services/identity/src/database.rs +++ b/services/identity/src/database.rs @@ -1,1226 +1,1228 @@ use comm_lib::aws::ddb::{ operation::{ delete_item::DeleteItemOutput, get_item::GetItemOutput, put_item::PutItemOutput, query::QueryOutput, }, primitives::Blob, types::{AttributeValue, PutRequest, WriteRequest}, }; use comm_lib::aws::{AwsConfig, DynamoDBClient}; use comm_lib::database::{ AttributeExtractor, AttributeMap, DBItemAttributeError, DBItemError, TryFromAttribute, }; use std::collections::{HashMap, HashSet}; use std::str::FromStr; use std::sync::Arc; pub use crate::database::device_list::DeviceIDAttribute; pub use crate::database::one_time_keys::OTKRow; use crate::{ constants::{error_types, USERS_TABLE_SOCIAL_PROOF_ATTRIBUTE_NAME}, ddb_utils::EthereumIdentity, device_list::SignedDeviceList, grpc_services::shared::PlatformMetadata, reserved_users::UserDetail, siwe::SocialProof, }; use crate::{ ddb_utils::{DBIdentity, OlmAccountType}, grpc_services::protos, }; use crate::{error::Error, grpc_utils::DeviceKeysInfo}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tracing::{debug, error, info, warn}; use crate::client_service::{FlattenedDeviceKeyUpload, UserRegistrationInfo}; use crate::config::CONFIG; use crate::constants::{ NONCE_TABLE, NONCE_TABLE_CREATED_ATTRIBUTE, NONCE_TABLE_EXPIRATION_TIME_ATTRIBUTE, NONCE_TABLE_EXPIRATION_TIME_UNIX_ATTRIBUTE, NONCE_TABLE_PARTITION_KEY, RESERVED_USERNAMES_TABLE, RESERVED_USERNAMES_TABLE_PARTITION_KEY, RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE, USERS_TABLE, USERS_TABLE_DEVICES_MAP_DEVICE_TYPE_ATTRIBUTE_NAME, USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME, USERS_TABLE_PARTITION_KEY, USERS_TABLE_REGISTRATION_ATTRIBUTE, USERS_TABLE_USERNAME_ATTRIBUTE, USERS_TABLE_USERNAME_INDEX, USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE, USERS_TABLE_WALLET_ADDRESS_INDEX, }; use crate::id::generate_uuid; use crate::nonce::NonceData; use crate::token::AuthType; pub use grpc_clients::identity::DeviceType; mod device_list; mod farcaster; mod one_time_keys; mod token; mod workflows; -pub use device_list::{DeviceListRow, DeviceListUpdate, DeviceRow}; +pub use device_list::{ + DeviceListRow, DeviceListUpdate, DeviceRow, PlatformDetails, +}; use self::device_list::Prekey; #[derive(Serialize, Deserialize)] pub struct OlmKeys { pub curve25519: String, pub ed25519: String, } #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KeyPayload { pub notification_identity_public_keys: OlmKeys, pub primary_identity_public_keys: OlmKeys, } impl FromStr for KeyPayload { type Err = serde_json::Error; // The payload is held in the database as an escaped JSON payload. // Escaped double quotes need to be trimmed before attempting to serialize fn from_str(payload: &str) -> Result { serde_json::from_str(&payload.replace(r#"\""#, r#"""#)) } } pub struct DBDeviceTypeInt(pub i32); impl TryFrom for DeviceType { type Error = crate::error::Error; fn try_from(value: DBDeviceTypeInt) -> Result { let device_result = DeviceType::try_from(value.0); device_result.map_err(|_| { Error::Attribute(DBItemError { attribute_name: USERS_TABLE_DEVICES_MAP_DEVICE_TYPE_ATTRIBUTE_NAME .to_string(), attribute_value: Some(AttributeValue::N(value.0.to_string())).into(), attribute_error: DBItemAttributeError::InvalidValue, }) }) } } pub struct OutboundKeys { pub key_payload: String, pub key_payload_signature: String, pub content_prekey: Prekey, pub notif_prekey: Prekey, pub content_one_time_key: Option, pub notif_one_time_key: Option, } impl From for protos::auth::OutboundKeyInfo { fn from(db_keys: OutboundKeys) -> Self { use protos::unauth::IdentityKeyInfo; Self { identity_info: Some(IdentityKeyInfo { payload: db_keys.key_payload, payload_signature: db_keys.key_payload_signature, }), content_prekey: Some(db_keys.content_prekey.into()), notif_prekey: Some(db_keys.notif_prekey.into()), one_time_content_prekey: db_keys.content_one_time_key, one_time_notif_prekey: db_keys.notif_one_time_key, } } } #[derive(Clone)] pub struct DatabaseClient { client: Arc, } impl DatabaseClient { pub fn new(aws_config: &AwsConfig) -> Self { let client = match &CONFIG.localstack_endpoint { Some(endpoint) => { info!( "Configuring DynamoDB client to use LocalStack endpoint: {}", endpoint ); let ddb_config_builder = comm_lib::aws::ddb::config::Builder::from(aws_config) .endpoint_url(endpoint); DynamoDBClient::from_conf(ddb_config_builder.build()) } None => DynamoDBClient::new(aws_config), }; DatabaseClient { client: Arc::new(client), } } pub async fn add_password_user_to_users_table( &self, registration_state: UserRegistrationInfo, password_file: Vec, platform_details: PlatformMetadata, access_token_creation_time: DateTime, ) -> Result { let device_key_upload = registration_state.flattened_device_key_upload; let user_id = self .add_user_to_users_table( Some((registration_state.username, Blob::new(password_file))), None, registration_state.user_id, registration_state.farcaster_id, ) .await?; // When initial device list is present, we should apply it // instead of auto-creating one. if let Some(device_list) = registration_state.initial_device_list { let initial_device_list = DeviceListUpdate::try_from(device_list)?; self .register_primary_device( &user_id, device_key_upload.clone(), platform_details, access_token_creation_time, initial_device_list, ) .await?; } else { self .add_device( &user_id, device_key_upload.clone(), platform_details, access_token_creation_time, ) .await?; } self .append_one_time_prekeys( &user_id, &device_key_upload.device_id_key, &device_key_upload.content_one_time_keys, &device_key_upload.notif_one_time_keys, ) .await?; Ok(user_id) } #[allow(clippy::too_many_arguments)] pub async fn add_wallet_user_to_users_table( &self, flattened_device_key_upload: FlattenedDeviceKeyUpload, wallet_address: String, social_proof: SocialProof, user_id: Option, platform_metadata: PlatformMetadata, access_token_creation_time: DateTime, farcaster_id: Option, initial_device_list: Option, ) -> Result { let wallet_identity = EthereumIdentity { wallet_address, social_proof, }; let user_id = self .add_user_to_users_table( None, Some(wallet_identity), user_id, farcaster_id, ) .await?; // When initial device list is present, we should apply it // instead of auto-creating one. if let Some(device_list) = initial_device_list { let initial_device_list = DeviceListUpdate::try_from(device_list)?; self .register_primary_device( &user_id, flattened_device_key_upload.clone(), platform_metadata, access_token_creation_time, initial_device_list, ) .await?; } else { self .add_device( &user_id, flattened_device_key_upload.clone(), platform_metadata, access_token_creation_time, ) .await?; } self .append_one_time_prekeys( &user_id, &flattened_device_key_upload.device_id_key, &flattened_device_key_upload.content_one_time_keys, &flattened_device_key_upload.notif_one_time_keys, ) .await?; Ok(user_id) } async fn add_user_to_users_table( &self, username_and_password_file: Option<(String, Blob)>, wallet_identity: Option, user_id: Option, farcaster_id: Option, ) -> Result { let user_id = user_id.unwrap_or_else(generate_uuid); let mut user = HashMap::from([( USERS_TABLE_PARTITION_KEY.to_string(), AttributeValue::S(user_id.clone()), )]); if let Some((username, password_file)) = username_and_password_file { user.insert( USERS_TABLE_USERNAME_ATTRIBUTE.to_string(), AttributeValue::S(username), ); user.insert( USERS_TABLE_REGISTRATION_ATTRIBUTE.to_string(), AttributeValue::B(password_file), ); } if let Some(eth_identity) = wallet_identity { user.insert( USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE.to_string(), AttributeValue::S(eth_identity.wallet_address), ); user.insert( USERS_TABLE_SOCIAL_PROOF_ATTRIBUTE_NAME.to_string(), eth_identity.social_proof.into(), ); } if let Some(fid) = farcaster_id { user.insert( USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME.to_string(), AttributeValue::S(fid), ); } self .client .put_item() .table_name(USERS_TABLE) .set_item(Some(user)) // make sure we don't accidentaly overwrite existing row .condition_expression("attribute_not_exists(#pk)") .expression_attribute_names("#pk", USERS_TABLE_PARTITION_KEY) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; Ok(user_id) } pub async fn add_user_device( &self, user_id: String, flattened_device_key_upload: FlattenedDeviceKeyUpload, platform_metadata: PlatformMetadata, access_token_creation_time: DateTime, ) -> Result<(), Error> { let content_one_time_keys = flattened_device_key_upload.content_one_time_keys.clone(); let notif_one_time_keys = flattened_device_key_upload.notif_one_time_keys.clone(); // add device to the device list if not exists let device_id = flattened_device_key_upload.device_id_key.clone(); let device_exists = self .device_exists(user_id.clone(), device_id.clone()) .await?; if device_exists { self .update_device_login_time( user_id.clone(), device_id, access_token_creation_time, ) .await?; return Ok(()); } // add device to the new device list self .add_device( &user_id, flattened_device_key_upload, platform_metadata, access_token_creation_time, ) .await?; self .append_one_time_prekeys( &user_id, &device_id, &content_one_time_keys, ¬if_one_time_keys, ) .await?; Ok(()) } pub async fn get_keyserver_keys_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 = user_devices .into_iter() .find(|device| *device.device_type() == GrpcDeviceType::Keyserver); let Some(keyserver) = maybe_keyserver_device else { return Ok(None); }; debug!( "Found keyserver in devices table (ID={})", &keyserver.device_id ); let (notif_one_time_key, requested_more_keys) = self .get_one_time_key( user_id, &keyserver.device_id, OlmAccountType::Notification, true, ) .await?; let (content_one_time_key, _) = self .get_one_time_key( user_id, &keyserver.device_id, OlmAccountType::Content, !requested_more_keys, ) .await?; debug!( "Able to get notif one-time key for keyserver {}: {}", &keyserver.device_id, notif_one_time_key.is_some() ); debug!( "Able to get content one-time key for keyserver {}: {}", &keyserver.device_id, content_one_time_key.is_some() ); let outbound_payload = OutboundKeys { key_payload: keyserver.device_key_info.key_payload, key_payload_signature: keyserver.device_key_info.key_payload_signature, content_prekey: keyserver.content_prekey, notif_prekey: keyserver.notif_prekey, content_one_time_key, notif_one_time_key, }; 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) } pub async fn update_user_password( &self, user_id: String, password_file: Vec, ) -> Result<(), Error> { let update_expression = format!("SET {} = :p", USERS_TABLE_REGISTRATION_ATTRIBUTE); let expression_attribute_values = HashMap::from([( ":p".to_string(), AttributeValue::B(Blob::new(password_file)), )]); self .client .update_item() .table_name(USERS_TABLE) .key(USERS_TABLE_PARTITION_KEY, AttributeValue::S(user_id)) .update_expression(update_expression) .set_expression_attribute_values(Some(expression_attribute_values)) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; Ok(()) } #[tracing::instrument(skip_all)] pub async fn delete_user( &self, user_id: String, ) -> Result { // We must delete the one-time keys first because doing so requires device // IDs from the devices table debug!(user_id, "Attempting to delete user's one-time keys"); self.delete_otks_table_rows_for_user(&user_id).await?; debug!(user_id, "Attempting to delete user's devices"); self.delete_devices_table_rows_for_user(&user_id).await?; debug!(user_id, "Attempting to delete user's access tokens"); self.delete_all_tokens_for_user(&user_id).await?; debug!(user_id, "Attempting to delete user"); match self .client .delete_item() .table_name(USERS_TABLE) .key( USERS_TABLE_PARTITION_KEY, AttributeValue::S(user_id.clone()), ) .send() .await { Ok(out) => { info!("User has been deleted {}", user_id); Ok(out) } Err(e) => { error!( errorType = error_types::GENERIC_DB_LOG, "DynamoDB client failed to delete user {}", user_id ); Err(Error::AwsSdk(e.into())) } } } pub async fn wallet_address_taken( &self, wallet_address: String, ) -> Result { let result = self .get_user_id_from_user_info(wallet_address, &AuthType::Wallet) .await?; Ok(result.is_some()) } pub async fn username_taken(&self, username: String) -> Result { let result = self .get_user_id_from_user_info(username, &AuthType::Password) .await?; Ok(result.is_some()) } pub async fn filter_out_taken_usernames( &self, user_details: Vec, ) -> Result, Error> { let db_usernames = self.get_all_usernames().await?; let db_usernames_set: HashSet = db_usernames.into_iter().collect(); let available_user_details: Vec = user_details .into_iter() .filter(|user_detail| !db_usernames_set.contains(&user_detail.username)) .collect(); Ok(available_user_details) } #[tracing::instrument(skip_all)] async fn get_user_from_user_info( &self, user_info: String, auth_type: &AuthType, ) -> Result>, Error> { let (index, attribute_name) = match auth_type { AuthType::Password => { (USERS_TABLE_USERNAME_INDEX, USERS_TABLE_USERNAME_ATTRIBUTE) } AuthType::Wallet => ( USERS_TABLE_WALLET_ADDRESS_INDEX, USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE, ), }; match self .client .query() .table_name(USERS_TABLE) .index_name(index) .key_condition_expression(format!("{} = :u", attribute_name)) .expression_attribute_values(":u", AttributeValue::S(user_info.clone())) .send() .await { Ok(QueryOutput { items: Some(items), .. }) => { let num_items = items.len(); if num_items == 0 { return Ok(None); } if num_items > 1 { warn!( "{} user IDs associated with {} {}: {:?}", num_items, attribute_name, user_info, items ); } let first_item = items[0].clone(); let user_id = first_item .get(USERS_TABLE_PARTITION_KEY) .ok_or(DBItemError { attribute_name: USERS_TABLE_PARTITION_KEY.to_string(), attribute_value: None.into(), attribute_error: DBItemAttributeError::Missing, })? .as_s() .map_err(|_| DBItemError { attribute_name: USERS_TABLE_PARTITION_KEY.to_string(), attribute_value: first_item .get(USERS_TABLE_PARTITION_KEY) .cloned() .into(), attribute_error: DBItemAttributeError::IncorrectType, })?; let result = self.get_item_from_users_table(user_id).await?; Ok(result.item) } Ok(_) => { info!( "No item found for {} {} in users table", attribute_name, user_info ); Ok(None) } Err(e) => { error!( errorType = error_types::GENERIC_DB_LOG, "DynamoDB client failed to get user from {} {}: {}", attribute_name, user_info, e ); Err(Error::AwsSdk(e.into())) } } } pub async fn get_keys_for_user( &self, user_id: &str, get_one_time_keys: bool, ) -> Result, Error> { let mut devices_response = self.get_keys_for_user_devices(user_id).await?; if devices_response.is_empty() { debug!("No devices found for user {}", user_id); return Ok(None); } if get_one_time_keys { for (device_id_key, device_keys) in devices_response.iter_mut() { let requested_more_keys; (device_keys.notif_one_time_key, requested_more_keys) = self .get_one_time_key( user_id, device_id_key, OlmAccountType::Notification, true, ) .await?; (device_keys.content_one_time_key, _) = self .get_one_time_key( user_id, device_id_key, OlmAccountType::Content, !requested_more_keys, ) .await?; } } Ok(Some(devices_response)) } pub async fn get_user_id_from_user_info( &self, user_info: String, auth_type: &AuthType, ) -> Result, Error> { match self .get_user_from_user_info(user_info.clone(), auth_type) .await { Ok(Some(mut user)) => user .take_attr(USERS_TABLE_PARTITION_KEY) .map(Some) .map_err(Error::Attribute), Ok(_) => Ok(None), Err(e) => Err(e), } } #[tracing::instrument(skip_all)] pub async fn get_user_id_and_password_file_from_username( &self, username: &str, ) -> Result)>, Error> { match self .get_user_from_user_info(username.to_string(), &AuthType::Password) .await { Ok(Some(mut user)) => { let user_id = user.take_attr(USERS_TABLE_PARTITION_KEY)?; let password_file = parse_registration_data_attribute( user.remove(USERS_TABLE_REGISTRATION_ATTRIBUTE), )?; Ok(Some((user_id, password_file))) } Ok(_) => { info!( "No item found for user {} in PAKE registration table", username ); Ok(None) } Err(e) => { error!( errorType = error_types::GENERIC_DB_LOG, "DynamoDB client failed to get registration data for user {}: {}", username, e ); Err(e) } } } pub async fn get_username_and_password_file( &self, user_id: &str, ) -> Result)>, Error> { let Some(mut user) = self.get_item_from_users_table(user_id).await?.item else { return Ok(None); }; let username = user.take_attr(USERS_TABLE_USERNAME_ATTRIBUTE)?; let password_file = parse_registration_data_attribute( user.remove(USERS_TABLE_REGISTRATION_ATTRIBUTE), )?; Ok(Some((username, password_file))) } async fn get_item_from_users_table( &self, user_id: &str, ) -> Result { let primary_key = create_simple_primary_key(( USERS_TABLE_PARTITION_KEY.to_string(), user_id.to_string(), )); self .client .get_item() .table_name(USERS_TABLE) .set_key(Some(primary_key)) .consistent_read(true) .send() .await .map_err(|e| Error::AwsSdk(e.into())) } pub async fn find_db_user_identities( &self, user_ids: impl IntoIterator, ) -> Result, Error> { use comm_lib::database::batch_operations::{ batch_get, ExponentialBackoffConfig, }; let primary_keys = user_ids.into_iter().map(|user_id| { create_simple_primary_key(( USERS_TABLE_PARTITION_KEY.to_string(), user_id, )) }); let projection_expression = [ USERS_TABLE_PARTITION_KEY, USERS_TABLE_USERNAME_ATTRIBUTE, USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE, USERS_TABLE_SOCIAL_PROOF_ATTRIBUTE_NAME, USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME, ] .join(", "); debug!( num_requests = primary_keys.size_hint().0, "Attempting to batch get user identifiers" ); let responses = batch_get( &self.client, USERS_TABLE, primary_keys, Some(projection_expression), ExponentialBackoffConfig::default(), ) .await .map_err(Error::from)?; debug!("Found {} matching user identifiers in DDB", responses.len()); let mut results = HashMap::with_capacity(responses.len()); for response in responses { let user_id = response.get_attr(USERS_TABLE_PARTITION_KEY)?; // if this fails, it means that projection expression didnt have all attrs it needed let identity = DBIdentity::try_from(response)?; results.insert(user_id, identity); } Ok(results) } /// Retrieves username for password users or wallet address for wallet users /// Returns `None` if user not found #[tracing::instrument(skip_all)] pub async fn get_user_identity( &self, user_id: &str, ) -> Result, Error> { self .get_item_from_users_table(user_id) .await? .item .map(DBIdentity::try_from) .transpose() .map_err(|e| { error!( user_id, errorType = error_types::GENERIC_DB_LOG, "Database item is missing an identifier" ); e }) } async fn get_all_usernames(&self) -> Result, Error> { let scan_output = self .client .scan() .table_name(USERS_TABLE) .projection_expression(USERS_TABLE_USERNAME_ATTRIBUTE) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; let mut result = Vec::new(); if let Some(attributes) = scan_output.items { for mut attribute in attributes { if let Ok(username) = attribute.take_attr(USERS_TABLE_USERNAME_ATTRIBUTE) { result.push(username); } } } Ok(result) } pub async fn get_all_user_details(&self) -> Result, Error> { let scan_output = self .client .scan() .table_name(USERS_TABLE) .projection_expression(format!( "{USERS_TABLE_USERNAME_ATTRIBUTE}, {USERS_TABLE_PARTITION_KEY}" )) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; let mut result = Vec::new(); if let Some(attributes) = scan_output.items { for mut attribute in attributes { if let (Ok(username), Ok(user_id)) = ( attribute.take_attr(USERS_TABLE_USERNAME_ATTRIBUTE), attribute.take_attr(USERS_TABLE_PARTITION_KEY), ) { result.push(UserDetail { username, user_id }); } } } Ok(result) } pub async fn get_all_reserved_user_details( &self, ) -> Result, Error> { let scan_output = self .client .scan() .table_name(RESERVED_USERNAMES_TABLE) .projection_expression(format!( "{RESERVED_USERNAMES_TABLE_PARTITION_KEY},\ {RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE}" )) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; let mut result = Vec::new(); if let Some(attributes) = scan_output.items { for mut attribute in attributes { if let (Ok(username), Ok(user_id)) = ( attribute.take_attr(USERS_TABLE_USERNAME_ATTRIBUTE), attribute.take_attr(RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE), ) { result.push(UserDetail { username, user_id }); } } } Ok(result) } pub async fn add_nonce_to_nonces_table( &self, nonce_data: NonceData, ) -> Result { let item = HashMap::from([ ( NONCE_TABLE_PARTITION_KEY.to_string(), AttributeValue::S(nonce_data.nonce), ), ( NONCE_TABLE_CREATED_ATTRIBUTE.to_string(), AttributeValue::S(nonce_data.created.to_rfc3339()), ), ( NONCE_TABLE_EXPIRATION_TIME_ATTRIBUTE.to_string(), AttributeValue::S(nonce_data.expiration_time.to_rfc3339()), ), ( NONCE_TABLE_EXPIRATION_TIME_UNIX_ATTRIBUTE.to_string(), AttributeValue::N(nonce_data.expiration_time.timestamp().to_string()), ), ]); self .client .put_item() .table_name(NONCE_TABLE) .set_item(Some(item)) .send() .await .map_err(|e| Error::AwsSdk(e.into())) } pub async fn get_nonce_from_nonces_table( &self, nonce_value: impl Into, ) -> Result, Error> { let get_response = self .client .get_item() .table_name(NONCE_TABLE) .key( NONCE_TABLE_PARTITION_KEY, AttributeValue::S(nonce_value.into()), ) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; let Some(mut item) = get_response.item else { return Ok(None); }; let nonce = item.take_attr(NONCE_TABLE_PARTITION_KEY)?; let created = DateTime::::try_from_attr( NONCE_TABLE_CREATED_ATTRIBUTE, item.remove(NONCE_TABLE_CREATED_ATTRIBUTE), )?; let expiration_time = DateTime::::try_from_attr( NONCE_TABLE_EXPIRATION_TIME_ATTRIBUTE, item.remove(NONCE_TABLE_EXPIRATION_TIME_ATTRIBUTE), )?; Ok(Some(NonceData { nonce, created, expiration_time, })) } pub async fn remove_nonce_from_nonces_table( &self, nonce: impl Into, ) -> Result<(), Error> { self .client .delete_item() .table_name(NONCE_TABLE) .key(NONCE_TABLE_PARTITION_KEY, AttributeValue::S(nonce.into())) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; Ok(()) } pub async fn add_usernames_to_reserved_usernames_table( &self, user_details: Vec, ) -> Result<(), Error> { // A single call to BatchWriteItem can consist of up to 25 operations for user_chunk in user_details.chunks(25) { let write_requests = user_chunk .iter() .map(|user_detail| { let put_request = PutRequest::builder() .item( RESERVED_USERNAMES_TABLE_PARTITION_KEY, AttributeValue::S(user_detail.username.to_string()), ) .item( RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE, AttributeValue::S(user_detail.user_id.to_string()), ) .build(); WriteRequest::builder().put_request(put_request).build() }) .collect(); self .client .batch_write_item() .request_items(RESERVED_USERNAMES_TABLE, write_requests) .send() .await .map_err(|e| Error::AwsSdk(e.into()))?; } info!("Batch write item to reserved usernames table succeeded"); Ok(()) } #[tracing::instrument(skip_all)] pub async fn delete_username_from_reserved_usernames_table( &self, username: String, ) -> Result { debug!( "Attempting to delete username {} from reserved usernames table", username ); match self .client .delete_item() .table_name(RESERVED_USERNAMES_TABLE) .key( RESERVED_USERNAMES_TABLE_PARTITION_KEY, AttributeValue::S(username.clone()), ) .send() .await { Ok(out) => { info!( "Username {} has been deleted from reserved usernames table", username ); Ok(out) } Err(e) => { error!(errorType = error_types::GENERIC_DB_LOG, "DynamoDB client failed to delete username {} from reserved usernames table", username); Err(Error::AwsSdk(e.into())) } } } pub async fn username_in_reserved_usernames_table( &self, username: &str, ) -> Result { match self .client .get_item() .table_name(RESERVED_USERNAMES_TABLE) .key( RESERVED_USERNAMES_TABLE_PARTITION_KEY.to_string(), AttributeValue::S(username.to_string()), ) .consistent_read(true) .send() .await { Ok(GetItemOutput { item: Some(_), .. }) => Ok(true), Ok(_) => Ok(false), Err(e) => Err(Error::AwsSdk(e.into())), } } } type AttributeName = String; type Devices = HashMap; fn create_simple_primary_key( partition_key: (AttributeName, String), ) -> HashMap { HashMap::from([(partition_key.0, AttributeValue::S(partition_key.1))]) } fn create_composite_primary_key( partition_key: (AttributeName, String), sort_key: (AttributeName, String), ) -> HashMap { let mut primary_key = create_simple_primary_key(partition_key); primary_key.insert(sort_key.0, AttributeValue::S(sort_key.1)); primary_key } fn parse_registration_data_attribute( attribute: Option, ) -> Result, DBItemError> { match attribute { Some(AttributeValue::B(server_registration_bytes)) => { Ok(server_registration_bytes.into_inner()) } Some(_) => Err(DBItemError::new( USERS_TABLE_REGISTRATION_ATTRIBUTE.to_string(), attribute.into(), DBItemAttributeError::IncorrectType, )), None => Err(DBItemError::new( USERS_TABLE_REGISTRATION_ATTRIBUTE.to_string(), attribute.into(), DBItemAttributeError::Missing, )), } } #[deprecated(note = "Use `comm_lib` counterpart instead")] #[allow(dead_code)] fn parse_map_attribute( attribute_name: &str, attribute_value: Option, ) -> Result { match attribute_value { Some(AttributeValue::M(map)) => Ok(map), Some(_) => { error!( attribute = attribute_name, value = ?attribute_value, error_type = "IncorrectType", errorType = error_types::GENERIC_DB_LOG, "Unexpected attribute type when parsing map attribute" ); Err(DBItemError::new( attribute_name.to_string(), attribute_value.into(), DBItemAttributeError::IncorrectType, )) } None => { error!( attribute = attribute_name, error_type = "Missing", errorType = error_types::GENERIC_DB_LOG, "Attribute is missing" ); Err(DBItemError::new( attribute_name.to_string(), attribute_value.into(), DBItemAttributeError::Missing, )) } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_create_simple_primary_key() { let partition_key_name = "userID".to_string(); let partition_key_value = "12345".to_string(); let partition_key = (partition_key_name.clone(), partition_key_value.clone()); let mut primary_key = create_simple_primary_key(partition_key); assert_eq!(primary_key.len(), 1); let attribute = primary_key.remove(&partition_key_name); assert!(attribute.is_some()); assert_eq!(attribute, Some(AttributeValue::S(partition_key_value))); } #[test] fn test_create_composite_primary_key() { let partition_key_name = "userID".to_string(); let partition_key_value = "12345".to_string(); let partition_key = (partition_key_name.clone(), partition_key_value.clone()); let sort_key_name = "deviceID".to_string(); let sort_key_value = "54321".to_string(); let sort_key = (sort_key_name.clone(), sort_key_value.clone()); let mut primary_key = create_composite_primary_key(partition_key, sort_key); assert_eq!(primary_key.len(), 2); let partition_key_attribute = primary_key.remove(&partition_key_name); assert!(partition_key_attribute.is_some()); assert_eq!( partition_key_attribute, Some(AttributeValue::S(partition_key_value)) ); let sort_key_attribute = primary_key.remove(&sort_key_name); assert!(sort_key_attribute.is_some()); assert_eq!(sort_key_attribute, Some(AttributeValue::S(sort_key_value))) } #[test] fn validate_keys() { // Taken from test user let example_payload = r#"{\"notificationIdentityPublicKeys\":{\"curve25519\":\"DYmV8VdkjwG/VtC8C53morogNJhpTPT/4jzW0/cxzQo\",\"ed25519\":\"D0BV2Y7Qm36VUtjwyQTJJWYAycN7aMSJmhEsRJpW2mk\"},\"primaryIdentityPublicKeys\":{\"curve25519\":\"Y4ZIqzpE1nv83kKGfvFP6rifya0itRg2hifqYtsISnk\",\"ed25519\":\"cSlL+VLLJDgtKSPlIwoCZg0h0EmHlQoJC08uV/O+jvg\"}}"#; let serialized_payload = KeyPayload::from_str(example_payload).unwrap(); assert_eq!( serialized_payload .notification_identity_public_keys .curve25519, "DYmV8VdkjwG/VtC8C53morogNJhpTPT/4jzW0/cxzQo" ); } #[test] fn test_int_to_device_type() { let valid_result = DeviceType::try_from(3); assert!(valid_result.is_ok()); assert_eq!(valid_result.unwrap(), DeviceType::Android); let invalid_result = DeviceType::try_from(6); assert!(invalid_result.is_err()); } } diff --git a/services/identity/src/database/device_list.rs b/services/identity/src/database/device_list.rs index 7c63dd7ba..deffb5aeb 100644 --- a/services/identity/src/database/device_list.rs +++ b/services/identity/src/database/device_list.rs @@ -1,1856 +1,1890 @@ use std::collections::HashMap; use chrono::{DateTime, Utc}; use comm_lib::{ aws::ddb::{ operation::{get_item::GetItemOutput, query::builders::QueryFluentBuilder}, types::{ error::TransactionCanceledException, AttributeValue, Delete, DeleteRequest, Put, TransactWriteItem, Update, WriteRequest, }, }, database::{ AttributeExtractor, AttributeMap, DBItemAttributeError, DBItemError, DynamoDBError, TryFromAttribute, }, }; use tracing::{debug, error, trace, warn}; use crate::{ client_service::FlattenedDeviceKeyUpload, constants::{ devices_table::{self, *}, error_types, USERS_TABLE, USERS_TABLE_DEVICELIST_TIMESTAMP_ATTRIBUTE_NAME, USERS_TABLE_PARTITION_KEY, }, error::{DeviceListError, Error}, grpc_services::{ protos::{self, unauth::DeviceType}, shared::PlatformMetadata, }, grpc_utils::DeviceKeysInfo, olm::is_valid_olm_key, }; use super::DatabaseClient; // We omit the content and notif one-time key count attributes from this struct // because they are internal helpers and are not provided by users #[derive(Clone, Debug)] pub struct DeviceRow { pub user_id: String, pub device_id: String, pub device_key_info: IdentityKeyInfo, pub content_prekey: Prekey, pub notif_prekey: Prekey, pub platform_details: PlatformDetails, /// Timestamp of last login (access token generation) pub login_time: DateTime, } #[derive(Clone, Debug)] pub struct DeviceListRow { pub user_id: String, pub timestamp: DateTime, pub device_ids: Vec, /// Primary device signature. This is `None` for Identity-generated lists. pub current_primary_signature: Option, /// Last primary device signature, in case the primary device has changed /// since last device list update. pub last_primary_signature: Option, } #[derive(Clone, Debug)] pub struct IdentityKeyInfo { pub key_payload: String, pub key_payload_signature: String, } #[derive(Clone, Debug)] pub struct Prekey { pub prekey: String, pub prekey_signature: String, } #[derive(Clone, Debug)] pub struct PlatformDetails { device_type: DeviceType, code_version: u64, state_version: Option, major_desktop_version: Option, } /// A struct representing device list update payload /// issued by the primary device. /// For the JSON payload, see [`crate::device_list::SignedDeviceList`] pub struct DeviceListUpdate { pub devices: Vec, pub timestamp: DateTime, /// Primary device signature. This is `None` for Identity-generated lists. pub current_primary_signature: Option, /// Last primary device signature, in case the primary device has changed /// since last device list update. pub last_primary_signature: Option, /// Raw update payload to verify signatures pub raw_payload: String, } impl DeviceRow { #[tracing::instrument(skip_all)] pub fn from_device_key_upload( user_id: impl Into, upload: FlattenedDeviceKeyUpload, platform_metadata: PlatformMetadata, login_time: DateTime, ) -> Result { if !is_valid_olm_key(&upload.content_prekey) || !is_valid_olm_key(&upload.notif_prekey) { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Invalid prekey format" ); return Err(Error::InvalidFormat); } let key_upload_device_type = DeviceType::from_str_name(upload.device_type.as_str_name()) .expect("DeviceType conversion failed. Identity client and server protos mismatch"); let platform_details = PlatformDetails::new(platform_metadata, Some(key_upload_device_type))?; let device_row = Self { user_id: user_id.into(), device_id: upload.device_id_key, device_key_info: IdentityKeyInfo { key_payload: upload.key_payload, key_payload_signature: upload.key_payload_signature, }, content_prekey: Prekey { prekey: upload.content_prekey, prekey_signature: upload.content_prekey_signature, }, notif_prekey: Prekey { prekey: upload.notif_prekey, prekey_signature: upload.notif_prekey_signature, }, platform_details, login_time, }; Ok(device_row) } pub fn device_type(&self) -> &DeviceType { &self.platform_details.device_type } } impl DeviceListRow { /// Generates new device list row from given devices. /// Used only for Identity-generated (unsigned) device lists. fn new( user_id: impl Into, device_ids: Vec, update_info: &UpdateOperationInfo, ) -> Self { Self { user_id: user_id.into(), device_ids, timestamp: update_info.timestamp.unwrap_or_else(Utc::now), current_primary_signature: update_info.current_signature.clone(), last_primary_signature: update_info.last_signature.clone(), } } pub fn has_device(&self, device_id: &String) -> bool { self.device_ids.contains(device_id) } pub fn is_primary_device(&self, device_id: &String) -> bool { self .device_ids .first() .filter(|it| *it == device_id) .is_some() } pub fn has_secondary_device(&self, device_id: &String) -> bool { self.has_device(device_id) && !self.is_primary_device(device_id) } } impl PlatformDetails { pub fn new( metadata: PlatformMetadata, key_upload_device_type: Option, ) -> Result { let PlatformMetadata { device_type, .. } = metadata; let metadata_device_type = DeviceType::from_str_name(&device_type.to_uppercase()); let device_type = match (metadata_device_type, key_upload_device_type) { (Some(metadata_value), None) => metadata_value, (Some(metadata_value), Some(key_upload_value)) => { if metadata_value != key_upload_value { warn!( "DeviceKeyUplaod device type ({}) mismatches request metadata platform ({}). {}", "Prefering value from key uplaod.", key_upload_value.as_str_name(), metadata_value.as_str_name() ); } key_upload_value } (None, Some(key_upload_value)) => key_upload_value, (None, None) => { warn!( "Received invalid device_type in request metadata: {}", device_type ); return Err(Error::InvalidFormat); } }; Ok(Self { device_type, code_version: metadata.code_version, state_version: metadata.state_version, major_desktop_version: metadata.major_desktop_version, }) } } // helper structs for converting to/from attribute values for sort key (a.k.a itemID) pub struct DeviceIDAttribute(pub String); struct DeviceListKeyAttribute(DateTime); impl From for AttributeValue { fn from(value: DeviceIDAttribute) -> Self { AttributeValue::S(format!("{DEVICE_ITEM_KEY_PREFIX}{}", value.0)) } } impl From for AttributeValue { fn from(value: DeviceListKeyAttribute) -> Self { AttributeValue::S(format!( "{DEVICE_LIST_KEY_PREFIX}{}", value.0.to_rfc3339() )) } } impl TryFrom> for DeviceIDAttribute { type Error = DBItemError; fn try_from(value: Option) -> Result { let item_id = String::try_from_attr(ATTR_ITEM_ID, value)?; // remove the device- prefix let device_id = item_id .strip_prefix(DEVICE_ITEM_KEY_PREFIX) .ok_or_else(|| DBItemError { attribute_name: ATTR_ITEM_ID.to_string(), attribute_value: item_id.clone().into(), attribute_error: DBItemAttributeError::InvalidValue, })? .to_string(); Ok(Self(device_id)) } } impl TryFrom> for DeviceListKeyAttribute { type Error = DBItemError; fn try_from(value: Option) -> Result { let item_id = String::try_from_attr(ATTR_ITEM_ID, value)?; // remove the device-list- prefix, then parse the timestamp let timestamp: DateTime = item_id .strip_prefix(DEVICE_LIST_KEY_PREFIX) .ok_or_else(|| DBItemError { attribute_name: ATTR_ITEM_ID.to_string(), attribute_value: item_id.clone().into(), attribute_error: DBItemAttributeError::InvalidValue, }) .and_then(|s| { s.parse().map_err(|e| { DBItemError::new( ATTR_ITEM_ID.to_string(), item_id.clone().into(), DBItemAttributeError::InvalidTimestamp(e), ) }) })?; Ok(Self(timestamp)) } } impl TryFrom for DeviceRow { type Error = DBItemError; fn try_from(mut attrs: AttributeMap) -> Result { let user_id = attrs.take_attr(ATTR_USER_ID)?; let DeviceIDAttribute(device_id) = attrs.remove(ATTR_ITEM_ID).try_into()?; let device_key_info = attrs .take_attr::(ATTR_DEVICE_KEY_INFO) .and_then(IdentityKeyInfo::try_from)?; let content_prekey = attrs .take_attr::(ATTR_CONTENT_PREKEY) .and_then(Prekey::try_from)?; let notif_prekey = attrs .take_attr::(ATTR_NOTIF_PREKEY) .and_then(Prekey::try_from)?; let login_time: DateTime = attrs.take_attr(ATTR_LOGIN_TIME)?; // New schema contains PlatformDetails attribute while legacy schema // contains "deviceType" and "codeVersion" top-level attributes let platform_details = match attrs .take_attr::>(ATTR_PLATFORM_DETAILS)? { Some(platform_details) => platform_details, None => { let raw_device_type: String = attrs.take_attr(OLD_ATTR_DEVICE_TYPE)?; let device_type = DeviceType::from_str_name(&raw_device_type) .ok_or_else(|| { DBItemError::new( OLD_ATTR_DEVICE_TYPE.to_string(), raw_device_type.into(), DBItemAttributeError::InvalidValue, ) })?; let code_version = attrs .remove(OLD_ATTR_CODE_VERSION) .and_then(|attr| attr.as_n().ok().cloned()) .and_then(|val| val.parse::().ok()) .unwrap_or_default(); PlatformDetails { device_type, code_version, state_version: None, major_desktop_version: None, } } }; Ok(Self { user_id, device_id, device_key_info, content_prekey, notif_prekey, platform_details, login_time, }) } } impl From for AttributeMap { fn from(value: DeviceRow) -> Self { HashMap::from([ (ATTR_USER_ID.to_string(), AttributeValue::S(value.user_id)), ( ATTR_ITEM_ID.to_string(), DeviceIDAttribute(value.device_id).into(), ), ( ATTR_PLATFORM_DETAILS.to_string(), value.platform_details.into(), ), ( ATTR_DEVICE_KEY_INFO.to_string(), value.device_key_info.into(), ), (ATTR_CONTENT_PREKEY.to_string(), value.content_prekey.into()), (ATTR_NOTIF_PREKEY.to_string(), value.notif_prekey.into()), // migration attributes ( ATTR_LOGIN_TIME.to_string(), AttributeValue::S(value.login_time.to_rfc3339()), ), ]) } } impl From for protos::unauth::IdentityKeyInfo { fn from(value: IdentityKeyInfo) -> Self { Self { payload: value.key_payload, payload_signature: value.key_payload_signature, } } } impl From for AttributeValue { fn from(value: IdentityKeyInfo) -> Self { let attrs = HashMap::from([ ( ATTR_KEY_PAYLOAD.to_string(), AttributeValue::S(value.key_payload), ), ( ATTR_KEY_PAYLOAD_SIGNATURE.to_string(), AttributeValue::S(value.key_payload_signature), ), ]); AttributeValue::M(attrs) } } impl TryFrom for IdentityKeyInfo { type Error = DBItemError; fn try_from(mut attrs: AttributeMap) -> Result { let key_payload = attrs.take_attr(ATTR_KEY_PAYLOAD)?; let key_payload_signature = attrs.take_attr(ATTR_KEY_PAYLOAD_SIGNATURE)?; Ok(Self { key_payload, key_payload_signature, }) } } impl From for AttributeValue { fn from(value: Prekey) -> Self { let attrs = HashMap::from([ (ATTR_PREKEY.to_string(), AttributeValue::S(value.prekey)), ( ATTR_PREKEY_SIGNATURE.to_string(), AttributeValue::S(value.prekey_signature), ), ]); AttributeValue::M(attrs) } } impl From for protos::unauth::Prekey { fn from(value: Prekey) -> Self { Self { prekey: value.prekey, prekey_signature: value.prekey_signature, } } } impl From for Prekey { fn from(value: protos::unauth::Prekey) -> Self { Self { prekey: value.prekey, prekey_signature: value.prekey_signature, } } } impl TryFrom for Prekey { type Error = DBItemError; fn try_from(mut attrs: AttributeMap) -> Result { let prekey = attrs.take_attr(ATTR_PREKEY)?; let prekey_signature = attrs.take_attr(ATTR_PREKEY_SIGNATURE)?; Ok(Self { prekey, prekey_signature, }) } } impl From for AttributeValue { fn from(value: PlatformDetails) -> Self { let mut attrs = HashMap::from([ ( ATTR_DEVICE_TYPE.to_string(), AttributeValue::S(value.device_type.as_str_name().to_string()), ), ( ATTR_CODE_VERSION.to_string(), AttributeValue::N(value.code_version.to_string()), ), ]); if let Some(state_version) = value.state_version { attrs.insert( ATTR_STATE_VERSION.to_string(), AttributeValue::N(state_version.to_string()), ); } if let Some(major_desktop_version) = value.major_desktop_version { attrs.insert( ATTR_STATE_VERSION.to_string(), AttributeValue::N(major_desktop_version.to_string()), ); } AttributeValue::M(attrs) } } impl TryFrom for PlatformDetails { type Error = DBItemError; fn try_from(mut attrs: AttributeMap) -> Result { let raw_device_type: String = attrs.take_attr(ATTR_DEVICE_TYPE)?; let device_type = DeviceType::from_str_name(&raw_device_type).ok_or_else(|| { DBItemError::new( ATTR_DEVICE_TYPE.to_string(), raw_device_type.into(), DBItemAttributeError::InvalidValue, ) })?; let code_version = attrs .remove(ATTR_CODE_VERSION) .and_then(|attr| attr.as_n().ok().cloned()) .and_then(|val| val.parse::().ok()) .unwrap_or_default(); let state_version = attrs .remove(ATTR_STATE_VERSION) .and_then(|attr| attr.as_n().ok().cloned()) .and_then(|val| val.parse::().ok()); let major_desktop_version = attrs .remove(ATTR_MAJOR_DESKTOP_VERSION) .and_then(|attr| attr.as_n().ok().cloned()) .and_then(|val| val.parse::().ok()); Ok(Self { device_type, code_version, state_version, major_desktop_version, }) } } impl TryFromAttribute for PlatformDetails { fn try_from_attr( attribute_name: impl Into, attribute: Option, ) -> Result { AttributeMap::try_from_attr(attribute_name, attribute) .and_then(PlatformDetails::try_from) } } impl From for protos::auth::PlatformDetails { fn from(value: PlatformDetails) -> Self { Self { device_type: value.device_type.into(), code_version: value.code_version, state_version: value.state_version, major_desktop_version: value.major_desktop_version, } } } impl TryFrom for DeviceListRow { type Error = DBItemError; fn try_from(mut attrs: AttributeMap) -> Result { let user_id = attrs.take_attr(ATTR_USER_ID)?; let DeviceListKeyAttribute(timestamp) = attrs.remove(ATTR_ITEM_ID).try_into()?; // validate timestamps are in sync let timestamps_match = attrs .remove(ATTR_TIMESTAMP) .and_then(|attr| attr.as_n().ok().cloned()) .and_then(|val| val.parse::().ok()) .filter(|val| *val == timestamp.timestamp_millis()) .is_some(); if !timestamps_match { warn!( "DeviceList timestamp mismatch for (userID={}, itemID={})", &user_id, timestamp.to_rfc3339() ); } let device_ids: Vec = attrs.take_attr(ATTR_DEVICE_IDS)?; let current_primary_signature = attrs.take_attr(ATTR_CURRENT_SIGNATURE)?; let last_primary_signature = attrs.take_attr(ATTR_LAST_SIGNATURE)?; Ok(Self { user_id, timestamp, device_ids, current_primary_signature, last_primary_signature, }) } } impl From for AttributeMap { fn from(device_list: DeviceListRow) -> Self { let mut attrs = HashMap::new(); attrs.insert( ATTR_USER_ID.to_string(), AttributeValue::S(device_list.user_id.clone()), ); attrs.insert( ATTR_ITEM_ID.to_string(), DeviceListKeyAttribute(device_list.timestamp).into(), ); attrs.insert( ATTR_TIMESTAMP.to_string(), AttributeValue::N(device_list.timestamp.timestamp_millis().to_string()), ); attrs.insert( ATTR_DEVICE_IDS.to_string(), AttributeValue::L( device_list .device_ids .into_iter() .map(AttributeValue::S) .collect(), ), ); if let Some(current_signature) = device_list.current_primary_signature { attrs.insert( ATTR_CURRENT_SIGNATURE.to_string(), AttributeValue::S(current_signature), ); } if let Some(last_signature) = device_list.last_primary_signature { attrs.insert( ATTR_CURRENT_SIGNATURE.to_string(), AttributeValue::S(last_signature), ); } attrs } } impl DatabaseClient { /// Retrieves user's current devices and their full data #[tracing::instrument(skip_all)] pub async fn get_current_devices( &self, user_id: impl Into, ) -> Result, Error> { let response = query_rows_with_prefix(self, user_id, DEVICE_ITEM_KEY_PREFIX) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to get current devices: {:?}", e ); Error::AwsSdk(e.into()) })?; let Some(rows) = response.items else { return Ok(Vec::new()); }; rows .into_iter() .map(DeviceRow::try_from) .collect::, DBItemError>>() .map_err(Error::from) } /// Gets user's device list history #[tracing::instrument(skip_all)] pub async fn get_device_list_history( &self, user_id: impl Into, since: Option>, ) -> Result, Error> { let rows = if let Some(since) = since { // When timestamp is provided, it's better to query device lists by timestamp LSI self .client .query() .table_name(devices_table::NAME) .index_name(devices_table::TIMESTAMP_INDEX_NAME) .consistent_read(true) .key_condition_expression("#user_id = :user_id AND #timestamp > :since") .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#timestamp", ATTR_TIMESTAMP) .expression_attribute_values( ":user_id", AttributeValue::S(user_id.into()), ) .expression_attribute_values( ":since", AttributeValue::N(since.timestamp_millis().to_string()), ) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to query device list updates by index: {:?}", e ); Error::AwsSdk(e.into()) })? .items } else { // Query all device lists for user query_rows_with_prefix(self, user_id, DEVICE_LIST_KEY_PREFIX) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to query device list updates (all): {:?}", e ); Error::AwsSdk(e.into()) })? .items }; rows .unwrap_or_default() .into_iter() .map(DeviceListRow::try_from) .collect::, DBItemError>>() .map_err(Error::from) } /// Returns all devices' keys for the given user. Response is in the same format /// as [DatabaseClient::get_keys_for_user] for compatibility reasons. #[tracing::instrument(skip_all)] pub async fn get_keys_for_user_devices( &self, user_id: impl Into, ) -> Result { let user_devices = self.get_current_devices(user_id).await?; let user_devices_keys = user_devices .into_iter() .map(|device| (device.device_id.clone(), DeviceKeysInfo::from(device))) .collect(); Ok(user_devices_keys) } #[tracing::instrument(skip_all)] pub async fn update_device_prekeys( &self, user_id: impl Into, device_id: impl Into, content_prekey: Prekey, notif_prekey: Prekey, ) -> Result<(), Error> { if !is_valid_olm_key(&content_prekey.prekey) || !is_valid_olm_key(¬if_prekey.prekey) { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Invalid prekey format" ); return Err(Error::InvalidFormat); } self .client .update_item() .table_name(devices_table::NAME) .key(ATTR_USER_ID, AttributeValue::S(user_id.into())) .key(ATTR_ITEM_ID, DeviceIDAttribute(device_id.into()).into()) .condition_expression( "attribute_exists(#user_id) AND attribute_exists(#item_id)", ) .update_expression( "SET #content_prekey = :content_prekey, #notif_prekey = :notif_prekey", ) .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#item_id", ATTR_ITEM_ID) .expression_attribute_names("#content_prekey", ATTR_CONTENT_PREKEY) .expression_attribute_names("#notif_prekey", ATTR_NOTIF_PREKEY) .expression_attribute_values(":content_prekey", content_prekey.into()) .expression_attribute_values(":notif_prekey", notif_prekey.into()) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to update device prekeys: {:?}", e ); Error::AwsSdk(e.into()) })?; Ok(()) } /// Checks if given device exists on user's current device list #[tracing::instrument(skip_all)] pub async fn device_exists( &self, user_id: impl Into, device_id: impl Into, ) -> Result { let GetItemOutput { item, .. } = self .client .get_item() .table_name(devices_table::NAME) .key(ATTR_USER_ID, AttributeValue::S(user_id.into())) .key(ATTR_ITEM_ID, DeviceIDAttribute(device_id.into()).into()) // only fetch the primary key, we don't need the rest .projection_expression(format!("{ATTR_USER_ID}, {ATTR_ITEM_ID}")) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to check if device exists: {:?}", e ); Error::AwsSdk(e.into()) })?; Ok(item.is_some()) } #[tracing::instrument(skip_all)] pub async fn get_device_data( &self, user_id: impl Into, device_id: impl Into, ) -> Result, Error> { let GetItemOutput { item, .. } = self .client .get_item() .table_name(devices_table::NAME) .key(ATTR_USER_ID, AttributeValue::S(user_id.into())) .key(ATTR_ITEM_ID, DeviceIDAttribute(device_id.into()).into()) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to fetch device data: {:?}", e ); Error::AwsSdk(e.into()) })?; let Some(attrs) = item else { return Ok(None); }; let device_data = DeviceRow::try_from(attrs)?; Ok(Some(device_data)) } /// Fails if the device list is empty #[tracing::instrument(skip_all)] pub async fn get_primary_device_data( &self, user_id: &str, ) -> Result { let device_list = self.get_current_device_list(user_id).await?; let Some(primary_device_id) = device_list .as_ref() .and_then(|list| list.device_ids.first()) else { error!( user_id, errorType = error_types::DEVICE_LIST_DB_LOG, "Device list is empty. Cannot fetch primary device" ); return Err(Error::DeviceList(DeviceListError::DeviceNotFound)); }; self .get_device_data(user_id, primary_device_id) .await? .ok_or_else(|| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Corrupt database. Missing primary device data for user {}", user_id ); Error::MissingItem }) } /// Required only for migration purposes (determining primary device) #[tracing::instrument(skip_all)] pub async fn update_device_login_time( &self, user_id: impl Into, device_id: impl Into, login_time: DateTime, ) -> Result<(), Error> { self .client .update_item() .table_name(devices_table::NAME) .key(ATTR_USER_ID, AttributeValue::S(user_id.into())) .key(ATTR_ITEM_ID, DeviceIDAttribute(device_id.into()).into()) .condition_expression( "attribute_exists(#user_id) AND attribute_exists(#item_id)", ) .update_expression("SET #login_time = :login_time") .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#item_id", ATTR_ITEM_ID) .expression_attribute_names("#login_time", ATTR_LOGIN_TIME) .expression_attribute_values( ":login_time", AttributeValue::S(login_time.to_rfc3339()), ) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to update device login time: {:?}", e ); Error::AwsSdk(e.into()) })?; Ok(()) } + #[tracing::instrument(skip_all)] + pub async fn update_device_platform_details( + &self, + user_id: impl Into, + device_id: impl Into, + platform_details: PlatformDetails, + ) -> Result<(), Error> { + self + .client + .update_item() + .table_name(devices_table::NAME) + .key(ATTR_USER_ID, AttributeValue::S(user_id.into())) + .key(ATTR_ITEM_ID, DeviceIDAttribute(device_id.into()).into()) + .condition_expression( + "attribute_exists(#user_id) AND attribute_exists(#item_id)", + ) + .update_expression("SET #platform_details = :platform_details") + .expression_attribute_names("#user_id", ATTR_USER_ID) + .expression_attribute_names("#item_id", ATTR_ITEM_ID) + .expression_attribute_names("#platform_details", ATTR_PLATFORM_DETAILS) + .expression_attribute_values(":platform_details", platform_details.into()) + .send() + .await + .map_err(|e| { + error!( + errorType = error_types::DEVICE_LIST_DB_LOG, + "Failed to update device platform details: {:?}", e + ); + Error::AwsSdk(e.into()) + })?; + + Ok(()) + } + #[tracing::instrument(skip_all)] pub async fn get_current_device_list( &self, user_id: impl Into, ) -> Result, Error> { self .client .query() .table_name(devices_table::NAME) .index_name(devices_table::TIMESTAMP_INDEX_NAME) .consistent_read(true) .key_condition_expression("#user_id = :user_id") // sort descending .scan_index_forward(false) .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_values( ":user_id", AttributeValue::S(user_id.into()), ) .limit(1) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to query device list updates by index: {:?}", e ); Error::AwsSdk(e.into()) })? .items .and_then(|mut items| items.pop()) .map(DeviceListRow::try_from) .transpose() .map_err(Error::from) } /// Adds device data to devices table. If the device already exists, its /// data is overwritten. This does not update the device list; the device ID /// should already be present in the device list. #[tracing::instrument(skip_all)] pub async fn put_device_data( &self, user_id: impl Into, device_key_upload: FlattenedDeviceKeyUpload, platform_metadata: PlatformMetadata, login_time: DateTime, ) -> Result<(), Error> { let content_one_time_keys = device_key_upload.content_one_time_keys.clone(); let notif_one_time_keys = device_key_upload.notif_one_time_keys.clone(); let user_id_string = user_id.into(); let new_device = DeviceRow::from_device_key_upload( user_id_string.clone(), device_key_upload, platform_metadata, login_time, )?; let device_id = new_device.device_id.clone(); self .client .put_item() .table_name(devices_table::NAME) .set_item(Some(new_device.into())) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to put device data: {:?}", e ); Error::AwsSdk(e.into()) })?; self .append_one_time_prekeys( &user_id_string, &device_id, &content_one_time_keys, ¬if_one_time_keys, ) .await?; Ok(()) } /// Removes device data from devices table. If the device doesn't exist, /// it is a no-op. This does not update the device list; the device ID /// should be removed from the device list separately. #[tracing::instrument(skip_all)] pub async fn remove_device_data( &self, user_id: impl Into, device_id: impl Into, ) -> Result<(), Error> { let user_id = user_id.into(); let device_id = device_id.into(); self .client .delete_item() .table_name(devices_table::NAME) .key(ATTR_USER_ID, AttributeValue::S(user_id)) .key(ATTR_ITEM_ID, DeviceIDAttribute(device_id).into()) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to delete device data: {:?}", e ); Error::AwsSdk(e.into()) })?; Ok(()) } /// Registers primary device for user, stores its signed device list pub async fn register_primary_device( &self, user_id: impl Into, device_key_upload: FlattenedDeviceKeyUpload, platform_metadata: PlatformMetadata, login_time: DateTime, initial_device_list: DeviceListUpdate, ) -> Result<(), Error> { let user_id: String = user_id.into(); self .transact_update_devicelist(&user_id, |device_ids, devices_data| { if !device_ids.is_empty() || !devices_data.is_empty() { warn!( "Tried creating initial device list for already existing user (userID={})", &user_id, ); return Err(Error::DeviceList(DeviceListError::DeviceAlreadyExists)); } // Set device list *device_ids = initial_device_list.devices.clone(); let primary_device = DeviceRow::from_device_key_upload( &user_id, device_key_upload, platform_metadata, login_time, )?; // Put device keys into DDB let put_device = Put::builder() .table_name(devices_table::NAME) .set_item(Some(primary_device.into())) .condition_expression( "attribute_not_exists(#user_id) AND attribute_not_exists(#item_id)", ) .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#item_id", ATTR_ITEM_ID) .build(); let put_device_operation = TransactWriteItem::builder().put(put_device).build(); let update_info = UpdateOperationInfo::primary_device_issued(initial_device_list) .with_ddb_operation(put_device_operation); Ok(update_info) }) .await?; Ok(()) } /// Adds new device to user's device list. If the device already exists, the /// operation fails. Transactionally generates new device list version. pub async fn add_device( &self, user_id: impl Into, device_key_upload: FlattenedDeviceKeyUpload, platform_metadata: PlatformMetadata, login_time: DateTime, ) -> Result<(), Error> { let user_id: String = user_id.into(); self .transact_update_devicelist(&user_id, |device_ids, mut devices_data| { let new_device = DeviceRow::from_device_key_upload( &user_id, device_key_upload, platform_metadata, login_time, )?; if device_ids.iter().any(|id| &new_device.device_id == id) { warn!( "Device already exists in user's device list \ (userID={}, deviceID={})", &user_id, &new_device.device_id ); return Err(Error::DeviceList(DeviceListError::DeviceAlreadyExists)); } device_ids.push(new_device.device_id.clone()); // Reorder devices (determine primary device again) devices_data.push(new_device.clone()); migration::reorder_device_list(&user_id, device_ids, &devices_data); // Put new device let put_device = Put::builder() .table_name(devices_table::NAME) .set_item(Some(new_device.into())) .condition_expression( "attribute_not_exists(#user_id) AND attribute_not_exists(#item_id)", ) .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#item_id", ATTR_ITEM_ID) .build(); let put_device_operation = TransactWriteItem::builder().put(put_device).build(); let update_info = UpdateOperationInfo::identity_generated() .with_ddb_operation(put_device_operation); Ok(update_info) }) .await?; Ok(()) } /// Removes device from user's device list. If the device doesn't exist, the /// operation fails. Transactionally generates new device list version. pub async fn remove_device( &self, user_id: impl Into, device_id: impl AsRef, ) -> Result<(), Error> { let user_id: String = user_id.into(); let device_id = device_id.as_ref(); self .transact_update_devicelist(&user_id, |device_ids, mut devices_data| { let device_exists = device_ids.iter().any(|id| id == device_id); if !device_exists { warn!( "Device doesn't exist in user's device list \ (userID={}, deviceID={})", &user_id, device_id ); return Err(Error::DeviceList(DeviceListError::DeviceNotFound)); } device_ids.retain(|id| id != device_id); // Reorder devices (determine primary device again) devices_data.retain(|d| d.device_id != device_id); migration::reorder_device_list(&user_id, device_ids, &devices_data); // Delete device DDB operation let delete_device = Delete::builder() .table_name(devices_table::NAME) .key(ATTR_USER_ID, AttributeValue::S(user_id.clone())) .key( ATTR_ITEM_ID, DeviceIDAttribute(device_id.to_string()).into(), ) .condition_expression( "attribute_exists(#user_id) AND attribute_exists(#item_id)", ) .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#item_id", ATTR_ITEM_ID) .build(); let operation = TransactWriteItem::builder().delete(delete_device).build(); let update_info = UpdateOperationInfo::identity_generated() .with_ddb_operation(operation); Ok(update_info) }) .await?; Ok(()) } /// applies updated device list received from primary device pub async fn apply_devicelist_update( &self, user_id: &str, update: DeviceListUpdate, // A function that receives previous and new device IDs and // returns boolean determining if the new device list is valid. validator_fn: impl Fn(&[&str], &[&str]) -> bool, ) -> Result { use std::collections::HashSet; let new_list = update.devices.clone(); let mut devices_being_removed: Vec = Vec::new(); let update_result = self .transact_update_devicelist(user_id, |current_list, _| { crate::device_list::verify_device_list_signatures( current_list.first(), &update, )?; let previous_device_ids: Vec<&str> = current_list.iter().map(AsRef::as_ref).collect(); let new_device_ids: Vec<&str> = new_list.iter().map(AsRef::as_ref).collect(); if !validator_fn(&previous_device_ids, &new_device_ids) { warn!("Received invalid device list update"); return Err(Error::DeviceList( DeviceListError::InvalidDeviceListUpdate, )); } // collect device IDs that were removed let previous_set: HashSet<&str> = previous_device_ids.into_iter().collect(); let new_set: HashSet<&str> = new_device_ids.into_iter().collect(); devices_being_removed .extend(previous_set.difference(&new_set).map(ToString::to_string)); debug!("Applying device list update"); *current_list = new_list; Ok(UpdateOperationInfo::primary_device_issued(update)) }) .await?; // delete device data and invalidate CSAT for removed devices debug!( "{} devices have been removed from device list. Clearing data...", devices_being_removed.len() ); for device_id in devices_being_removed { trace!("Invalidating CSAT for device {}", device_id); self.delete_access_token_data(user_id, &device_id).await?; trace!("Clearing keys for device {}", device_id); self.remove_device_data(user_id, &device_id).await?; trace!("Pruning OTKs for device {}", device_id); self .delete_otks_table_rows_for_user_device(user_id, &device_id) .await?; } Ok(update_result) } /// 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 /// updated concurrently (timestamp mismatch). /// Returns the new device list row that has been saved to database. #[tracing::instrument(skip_all)] async fn transact_update_devicelist( &self, user_id: &str, // The closure performing a transactional update of the device list. // It receives two arguments: // 1. A mutable reference to the current device list (ordered device IDs). // 2. Details (full data) of the current devices (unordered). // The closure should return a [`UpdateOperationInfo`] object. action: impl FnOnce( &mut Vec, Vec, ) -> Result, ) -> Result { let previous_timestamp = get_current_devicelist_timestamp(self, user_id).await?; let current_devices_data = self.get_current_devices(user_id).await?; let mut device_ids = self .get_current_device_list(user_id) .await? .map(|device_list| device_list.device_ids) .unwrap_or_default(); // Perform the update action, then generate new device list let update_info = action(&mut device_ids, current_devices_data)?; crate::device_list::verify_device_list_timestamp( previous_timestamp.as_ref(), update_info.timestamp.as_ref(), )?; let new_device_list = DeviceListRow::new(user_id, device_ids, &update_info); // Update timestamp in users table let timestamp_update_operation = device_list_timestamp_update_operation( user_id, previous_timestamp, new_device_list.timestamp, ); // Put updated device list (a new version) let put_device_list = Put::builder() .table_name(devices_table::NAME) .set_item(Some(new_device_list.clone().into())) .condition_expression( "attribute_not_exists(#user_id) AND attribute_not_exists(#item_id)", ) .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#item_id", ATTR_ITEM_ID) .build(); let put_device_list_operation = TransactWriteItem::builder().put(put_device_list).build(); let operations = if let Some(operation) = update_info.ddb_operation { vec![ operation, put_device_list_operation, timestamp_update_operation, ] } else { vec![put_device_list_operation, timestamp_update_operation] }; self .client .transact_write_items() .set_transact_items(Some(operations)) .send() .await .map_err(|e| match DynamoDBError::from(e) { DynamoDBError::TransactionCanceledException( TransactionCanceledException { cancellation_reasons: Some(reasons), .. }, ) if reasons .iter() .any(|reason| reason.code() == Some("ConditionalCheckFailed")) => { Error::DeviceList(DeviceListError::ConcurrentUpdateError) } other => { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Device list update transaction failed: {:?}", other ); Error::AwsSdk(other) } })?; Ok(new_device_list) } /// Deletes all user data from devices table #[tracing::instrument(skip_all)] pub async fn delete_devices_table_rows_for_user( &self, user_id: impl Into, ) -> Result<(), Error> { // 1. get all rows // 2. batch write delete all // we project only the primary keys so we can pass these directly to delete requests let primary_keys = self .client .query() .table_name(devices_table::NAME) .projection_expression("#user_id, #item_id") .key_condition_expression("#user_id = :user_id") .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#item_id", ATTR_ITEM_ID) .expression_attribute_values( ":user_id", AttributeValue::S(user_id.into()), ) .consistent_read(true) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to list user's items in devices table: {:?}", e ); Error::AwsSdk(e.into()) })? .items .unwrap_or_default(); let delete_requests = primary_keys .into_iter() .map(|item| { let request = DeleteRequest::builder().set_key(Some(item)).build(); WriteRequest::builder().delete_request(request).build() }) .collect::>(); // TODO: We can use the batch write helper from comm-services-lib when integrated for batch in delete_requests.chunks(25) { self .client .batch_write_item() .request_items(devices_table::NAME, batch.to_vec()) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to batch delete items from devices table: {:?}", e ); Error::AwsSdk(e.into()) })?; } Ok(()) } } /// Gets timestamp of user's current device list. Returns None if the user /// doesn't have a device list yet. Storing the timestamp in the users table is /// required for consistency. It's used as a condition when updating the device /// list. #[tracing::instrument(skip_all)] async fn get_current_devicelist_timestamp( db: &crate::database::DatabaseClient, user_id: impl Into, ) -> Result>, Error> { let response = db .client .get_item() .table_name(USERS_TABLE) .key(USERS_TABLE_PARTITION_KEY, AttributeValue::S(user_id.into())) .projection_expression(USERS_TABLE_DEVICELIST_TIMESTAMP_ATTRIBUTE_NAME) .send() .await .map_err(|e| { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Failed to get user's device list timestamp: {:?}", e ); Error::AwsSdk(e.into()) })?; let mut user_item = response.item.unwrap_or_default(); let raw_datetime = user_item.remove(USERS_TABLE_DEVICELIST_TIMESTAMP_ATTRIBUTE_NAME); // existing records will not have this field when // updating device list for the first time if raw_datetime.is_none() { return Ok(None); } let timestamp = DateTime::::try_from_attr( USERS_TABLE_DEVICELIST_TIMESTAMP_ATTRIBUTE_NAME, raw_datetime, )?; Ok(Some(timestamp)) } /// Generates update expression for current device list timestamp in users table. /// The previous timestamp is used as a condition to ensure that the value hasn't changed /// since we got it. This avoids race conditions when updating the device list. fn device_list_timestamp_update_operation( user_id: impl Into, previous_timestamp: Option>, new_timestamp: DateTime, ) -> TransactWriteItem { let update_builder = match previous_timestamp { Some(previous_timestamp) => Update::builder() .condition_expression("#device_list_timestamp = :previous_timestamp") .expression_attribute_values( ":previous_timestamp", AttributeValue::S(previous_timestamp.to_rfc3339()), ), // If there's no previous timestamp, the attribute shouldn't exist yet None => Update::builder() .condition_expression("attribute_not_exists(#device_list_timestamp)"), }; let update = update_builder .table_name(USERS_TABLE) .key(USERS_TABLE_PARTITION_KEY, AttributeValue::S(user_id.into())) .update_expression("SET #device_list_timestamp = :new_timestamp") .expression_attribute_names( "#device_list_timestamp", USERS_TABLE_DEVICELIST_TIMESTAMP_ATTRIBUTE_NAME, ) .expression_attribute_values( ":new_timestamp", AttributeValue::S(new_timestamp.to_rfc3339()), ) .build(); TransactWriteItem::builder().update(update).build() } /// Helper function to query rows by given sort key prefix fn query_rows_with_prefix( db: &crate::database::DatabaseClient, user_id: impl Into, prefix: &'static str, ) -> QueryFluentBuilder { db.client .query() .table_name(devices_table::NAME) .key_condition_expression( "#user_id = :user_id AND begins_with(#item_id, :device_prefix)", ) .expression_attribute_names("#user_id", ATTR_USER_ID) .expression_attribute_names("#item_id", ATTR_ITEM_ID) .expression_attribute_values(":user_id", AttributeValue::S(user_id.into())) .expression_attribute_values( ":device_prefix", AttributeValue::S(prefix.to_string()), ) .consistent_read(true) } /// [`transact_update_devicelist()`] closure result struct UpdateOperationInfo { /// (optional) transactional DDB operation to be performed /// when updating the device list. ddb_operation: Option, /// new device list timestamp. Defaults to `Utc::now()` /// for Identity-generated device lists. timestamp: Option>, current_signature: Option, last_signature: Option, } impl UpdateOperationInfo { fn identity_generated() -> Self { Self { ddb_operation: None, timestamp: None, current_signature: None, last_signature: None, } } fn primary_device_issued(source: DeviceListUpdate) -> Self { Self { ddb_operation: None, timestamp: Some(source.timestamp), current_signature: source.current_primary_signature, last_signature: source.last_primary_signature, } } fn with_ddb_operation(mut self, operation: TransactWriteItem) -> Self { self.ddb_operation = Some(operation); self } } // Helper module for "migration" code into new device list schema. // We can get rid of this when primary device takes over the responsibility // of managing the device list. mod migration { use std::{cmp::Ordering, collections::HashSet}; use tracing::{debug, error, info}; use super::*; #[tracing::instrument(skip_all)] pub(super) fn reorder_device_list( user_id: &str, list: &mut [String], devices_data: &[DeviceRow], ) { if !verify_device_list_match(list, devices_data) { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Device list for user (userID={}) out of sync!", user_id ); return; } let Some(first_device) = list.first() else { debug!("Skipping device list rotation. Nothing to reorder."); return; }; let Some(primary_device) = determine_primary_device(devices_data) else { info!( "No valid primary device found for user (userID={}).\ Skipping device list reorder.", user_id ); return; }; if first_device == &primary_device.device_id { debug!("Skipping device list reorder. Primary device is already first"); return; } // swap primary device with the first one let Some(primary_device_idx) = list.iter().position(|id| id == &primary_device.device_id) else { error!( errorType = error_types::DEVICE_LIST_DB_LOG, "Primary device not found in device list (userID={})", user_id ); return; }; list.swap(0, primary_device_idx); info!("Reordered device list for user (userID={})", user_id); } // checks if device list matches given devices data #[tracing::instrument(skip_all)] fn verify_device_list_match( list: &[String], devices_data: &[DeviceRow], ) -> bool { if list.len() != devices_data.len() { debug!( list_len = list.len(), data_len = devices_data.len(), "Device list length mismatch!" ); return false; } let actual_device_ids = devices_data .iter() .map(|device| &device.device_id) .collect::>(); let device_list_set = list.iter().collect::>(); if let Some(unknown_device_id) = device_list_set .symmetric_difference(&actual_device_ids) .next() { debug!( "Device list and data out of sync (unknown deviceID={})", unknown_device_id ); return false; } true } /// Returns reference to primary device (if any) from given list of devices /// or None if there's no valid primary device. fn determine_primary_device(devices: &[DeviceRow]) -> Option<&DeviceRow> { // 1. Find mobile devices with valid token // 2. Prioritize these with latest code version // 3. If there's a tie, select the one with latest login time let mut mobile_devices = devices .iter() .filter(|device| { *device.device_type() == DeviceType::Ios || *device.device_type() == DeviceType::Android }) .collect::>(); mobile_devices.sort_by(|a, b| { let code_version_cmp = b .platform_details .code_version .cmp(&a.platform_details.code_version); if code_version_cmp == Ordering::Equal { b.login_time.cmp(&a.login_time) } else { code_version_cmp } }); mobile_devices.first().cloned() } #[cfg(test)] mod tests { use super::*; use chrono::Duration; #[test] fn reorder_skips_no_devices() { let mut list = vec![]; reorder_device_list("", &mut list, &[]); assert_eq!(list, Vec::::new()); } #[test] fn reorder_skips_single_device() { let mut list = vec!["test".into()]; let devices_data = vec![create_test_device("test", DeviceType::Web, 0, Utc::now())]; reorder_device_list("", &mut list, &devices_data); assert_eq!(list, vec!["test"]); } #[test] fn reorder_skips_for_valid_list() { let mut list = vec!["mobile".into(), "web".into()]; let devices_data = vec![ create_test_device("mobile", DeviceType::Android, 1, Utc::now()), create_test_device("web", DeviceType::Web, 0, Utc::now()), ]; reorder_device_list("", &mut list, &devices_data); assert_eq!(list, vec!["mobile", "web"]); } #[test] fn reorder_swaps_primary_device_when_possible() { let mut list = vec!["web".into(), "mobile".into()]; let devices_data = vec![ create_test_device("web", DeviceType::Web, 0, Utc::now()), create_test_device("mobile", DeviceType::Android, 1, Utc::now()), ]; reorder_device_list("", &mut list, &devices_data); assert_eq!(list, vec!["mobile", "web"]); } #[test] fn determine_primary_device_returns_none_for_empty_list() { let devices = vec![]; assert!(determine_primary_device(&devices).is_none()); } #[test] fn determine_primary_device_returns_none_for_web_only() { let devices = vec![create_test_device("web", DeviceType::Web, 0, Utc::now())]; assert!( determine_primary_device(&devices).is_none(), "Primary device should be None for web-only devices" ); } #[test] fn determine_primary_device_prioritizes_mobile() { let devices = vec![ create_test_device("mobile", DeviceType::Android, 0, Utc::now()), create_test_device("web", DeviceType::Web, 0, Utc::now()), ]; let primary_device = determine_primary_device(&devices) .expect("Primary device should be present"); assert_eq!( primary_device.device_id, "mobile", "Primary device should be mobile" ); } #[test] fn determine_primary_device_prioritizes_latest_code_version() { let devices_with_latest_code_version = vec![ create_test_device("mobile1", DeviceType::Android, 1, Utc::now()), create_test_device("mobile2", DeviceType::Android, 2, Utc::now()), create_test_device("web", DeviceType::Web, 0, Utc::now()), ]; let primary_device = determine_primary_device(&devices_with_latest_code_version) .expect("Primary device should be present"); assert_eq!( primary_device.device_id, "mobile2", "Primary device should be mobile with latest code version" ); } #[test] fn determine_primary_device_prioritizes_latest_login_time() { let devices = vec![ create_test_device("mobile1_today", DeviceType::Ios, 1, Utc::now()), create_test_device( "mobile2_yesterday", DeviceType::Android, 1, Utc::now() - Duration::days(1), ), create_test_device("web", DeviceType::Web, 0, Utc::now()), ]; let primary_device = determine_primary_device(&devices) .expect("Primary device should be present"); assert_eq!( primary_device.device_id, "mobile1_today", "Primary device should be mobile with latest login time" ); } #[test] fn determine_primary_device_keeps_deterministic_order() { // Given two identical devices, the first one should be selected as primary let today = Utc::now(); let devices_with_latest_code_version = vec![ create_test_device("mobile1", DeviceType::Android, 1, today), create_test_device("mobile2", DeviceType::Android, 1, today), ]; let primary_device = determine_primary_device(&devices_with_latest_code_version) .expect("Primary device should be present"); assert_eq!( primary_device.device_id, "mobile1", "Primary device selection should be deterministic" ); } #[test] fn determine_primary_device_all_rules_together() { use DeviceType::{Android, Ios, Web}; let today = Utc::now(); let yesterday = today - Duration::days(1); let devices = vec![ create_test_device("mobile1_today", Android, 1, today), create_test_device("mobile2_today", Android, 2, today), create_test_device("mobile3_yesterday", Ios, 1, yesterday), create_test_device("mobile4_yesterday", Ios, 2, yesterday), create_test_device("web", Web, 5, today), ]; let primary_device = determine_primary_device(&devices) .expect("Primary device should be present"); assert_eq!( primary_device.device_id, "mobile2_today", "Primary device should be mobile with latest code version and login time" ); } fn create_test_device( id: &str, platform: DeviceType, code_version: u64, login_time: DateTime, ) -> DeviceRow { DeviceRow { user_id: "test".into(), device_id: id.into(), device_key_info: IdentityKeyInfo { key_payload: "".into(), key_payload_signature: "".into(), }, content_prekey: Prekey { prekey: "".into(), prekey_signature: "".into(), }, notif_prekey: Prekey { prekey: "".into(), prekey_signature: "".into(), }, platform_details: PlatformDetails { device_type: platform, code_version, state_version: None, major_desktop_version: None, }, login_time, } } } } diff --git a/services/identity/src/grpc_services/authenticated.rs b/services/identity/src/grpc_services/authenticated.rs index a991e70c2..ce2afcc27 100644 --- a/services/identity/src/grpc_services/authenticated.rs +++ b/services/identity/src/grpc_services/authenticated.rs @@ -1,770 +1,789 @@ use std::collections::HashMap; use crate::config::CONFIG; -use crate::database::DeviceListUpdate; +use crate::database::{DeviceListUpdate, PlatformDetails}; use crate::device_list::SignedDeviceList; use crate::{ client_service::{handle_db_error, UpdateState, WorkflowInProgress}, constants::{error_types, request_metadata}, database::DatabaseClient, - grpc_services::shared::get_value, + grpc_services::shared::{get_platform_metadata, get_value}, }; use chrono::DateTime; use comm_opaque2::grpc::protocol_error_to_grpc_status; use tonic::{Request, Response, Status}; use tracing::{debug, error, trace, warn}; use super::protos::auth::{ identity_client_service_server::IdentityClientService, DeletePasswordUserFinishRequest, DeletePasswordUserStartRequest, DeletePasswordUserStartResponse, GetDeviceListRequest, GetDeviceListResponse, InboundKeyInfo, InboundKeysForUserRequest, InboundKeysForUserResponse, KeyserverKeysResponse, LinkFarcasterAccountRequest, OutboundKeyInfo, OutboundKeysForUserRequest, OutboundKeysForUserResponse, PeersDeviceListsRequest, PeersDeviceListsResponse, RefreshUserPrekeysRequest, UpdateDeviceListRequest, UpdateUserPasswordFinishRequest, UpdateUserPasswordStartRequest, UpdateUserPasswordStartResponse, UploadOneTimeKeysRequest, UserDevicesPlatformDetails, UserIdentitiesRequest, UserIdentitiesResponse, }; use super::protos::unauth::Empty; #[derive(derive_more::Constructor)] pub struct AuthenticatedService { db_client: DatabaseClient, } fn get_auth_info(req: &Request<()>) -> Option<(String, String, String)> { trace!("Retrieving auth info for request: {:?}", req); let user_id = get_value(req, request_metadata::USER_ID)?; let device_id = get_value(req, request_metadata::DEVICE_ID)?; let access_token = get_value(req, request_metadata::ACCESS_TOKEN)?; Some((user_id, device_id, access_token)) } pub fn auth_interceptor( req: Request<()>, db_client: &DatabaseClient, ) -> Result, Status> { trace!("Intercepting request to check auth info: {:?}", req); let (user_id, device_id, access_token) = get_auth_info(&req) .ok_or_else(|| Status::unauthenticated("Missing credentials"))?; let handle = tokio::runtime::Handle::current(); let new_db_client = db_client.clone(); // This function cannot be `async`, yet must call the async db call // Force tokio to resolve future in current thread without an explicit .await let valid_token = tokio::task::block_in_place(move || { handle .block_on(new_db_client.verify_access_token( user_id, device_id, access_token, )) .map_err(handle_db_error) })?; if !valid_token { return Err(Status::aborted("Bad Credentials")); } Ok(req) } pub fn get_user_and_device_id( request: &Request, ) -> Result<(String, String), Status> { let user_id = get_value(request, request_metadata::USER_ID) .ok_or_else(|| Status::unauthenticated("Missing user_id field"))?; let device_id = get_value(request, request_metadata::DEVICE_ID) .ok_or_else(|| Status::unauthenticated("Missing device_id field"))?; Ok((user_id, device_id)) } #[tonic::async_trait] impl IdentityClientService for AuthenticatedService { #[tracing::instrument(skip_all)] async fn refresh_user_prekeys( &self, request: Request, ) -> Result, Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; let message = request.into_inner(); debug!("Refreshing prekeys for user: {}", user_id); let content_keys = message .new_content_prekeys .ok_or_else(|| Status::invalid_argument("Missing content keys"))?; let notif_keys = message .new_notif_prekeys .ok_or_else(|| Status::invalid_argument("Missing notification keys"))?; self .db_client .update_device_prekeys( user_id, device_id, content_keys.into(), notif_keys.into(), ) .await .map_err(handle_db_error)?; let response = Response::new(Empty {}); Ok(response) } #[tracing::instrument(skip_all)] async fn get_outbound_keys_for_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let user_id = &message.user_id; let devices_map = self .db_client .get_keys_for_user(user_id, true) .await .map_err(handle_db_error)? .ok_or_else(|| tonic::Status::not_found("user not found"))?; let transformed_devices = devices_map .into_iter() .map(|(key, device_info)| (key, OutboundKeyInfo::from(device_info))) .collect::>(); Ok(tonic::Response::new(OutboundKeysForUserResponse { devices: transformed_devices, })) } #[tracing::instrument(skip_all)] async fn get_inbound_keys_for_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let user_id = &message.user_id; let devices_map = self .db_client .get_keys_for_user(user_id, false) .await .map_err(handle_db_error)? .ok_or_else(|| tonic::Status::not_found("user not found"))?; let transformed_devices = devices_map .into_iter() .map(|(key, device_info)| (key, InboundKeyInfo::from(device_info))) .collect::>(); let identifier = self .db_client .get_user_identity(user_id) .await .map_err(handle_db_error)? .ok_or_else(|| tonic::Status::not_found("user not found"))?; Ok(tonic::Response::new(InboundKeysForUserResponse { devices: transformed_devices, identity: Some(identifier.into()), })) } #[tracing::instrument(skip_all)] async fn get_keyserver_keys( &self, request: Request, ) -> Result, Status> { let message = request.into_inner(); let identifier = self .db_client .get_user_identity(&message.user_id) .await .map_err(handle_db_error)? .ok_or_else(|| tonic::Status::not_found("user not found"))?; let Some(keyserver_info) = self .db_client .get_keyserver_keys_for_user(&message.user_id) .await .map_err(handle_db_error)? else { return Err(Status::not_found("keyserver not found")); }; let primary_device_data = self .db_client .get_primary_device_data(&message.user_id) .await .map_err(handle_db_error)?; let primary_device_keys = primary_device_data.device_key_info; let response = Response::new(KeyserverKeysResponse { keyserver_info: Some(keyserver_info.into()), identity: Some(identifier.into()), primary_device_identity_info: Some(primary_device_keys.into()), }); return Ok(response); } #[tracing::instrument(skip_all)] async fn upload_one_time_keys( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; let message = request.into_inner(); debug!("Attempting to update one time keys for user: {}", user_id); self .db_client .append_one_time_prekeys( &user_id, &device_id, &message.content_one_time_prekeys, &message.notif_one_time_prekeys, ) .await .map_err(handle_db_error)?; Ok(tonic::Response::new(Empty {})) } #[tracing::instrument(skip_all)] async fn update_user_password_start( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; let message = request.into_inner(); let server_registration = comm_opaque2::server::Registration::new(); let server_message = server_registration .start( &CONFIG.server_setup, &message.opaque_registration_request, user_id.as_bytes(), ) .map_err(protocol_error_to_grpc_status)?; let update_state = UpdateState { user_id }; let session_id = self .db_client .insert_workflow(WorkflowInProgress::Update(update_state)) .await .map_err(handle_db_error)?; let response = UpdateUserPasswordStartResponse { session_id, opaque_registration_response: server_message, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn update_user_password_finish( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let Some(WorkflowInProgress::Update(state)) = self .db_client .get_workflow(message.session_id) .await .map_err(handle_db_error)? else { return Err(tonic::Status::not_found("session not found")); }; let server_registration = comm_opaque2::server::Registration::new(); let password_file = server_registration .finish(&message.opaque_registration_upload) .map_err(protocol_error_to_grpc_status)?; self .db_client .update_user_password(state.user_id, password_file) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn log_out_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; self .db_client .remove_device(&user_id, &device_id) .await .map_err(handle_db_error)?; self .db_client .delete_otks_table_rows_for_user_device(&user_id, &device_id) .await .map_err(handle_db_error)?; self .db_client .delete_access_token_data(user_id, device_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn log_out_secondary_device( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, device_id) = get_user_and_device_id(&request)?; debug!( "Secondary device logout request for user_id={}, device_id={}", user_id, device_id ); self .verify_device_on_device_list( &user_id, &device_id, DeviceListItemKind::Secondary, ) .await?; self .db_client .delete_access_token_data(&user_id, &device_id) .await .map_err(handle_db_error)?; self .db_client .delete_otks_table_rows_for_user_device(&user_id, &device_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn delete_wallet_user( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; debug!("Attempting to delete wallet user: {}", user_id); self .db_client .delete_user(user_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn delete_password_user_start( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; let message = request.into_inner(); debug!("Attempting to start deleting password user: {}", user_id); let maybe_username_and_password_file = self .db_client .get_username_and_password_file(&user_id) .await .map_err(handle_db_error)?; let Some((username, password_file_bytes)) = maybe_username_and_password_file else { return Err(tonic::Status::not_found("user not found")); }; let mut server_login = comm_opaque2::server::Login::new(); let server_response = server_login .start( &CONFIG.server_setup, &password_file_bytes, &message.opaque_login_request, username.as_bytes(), ) .map_err(protocol_error_to_grpc_status)?; let delete_state = construct_delete_password_user_info(server_login); let session_id = self .db_client .insert_workflow(WorkflowInProgress::PasswordUserDeletion(Box::new( delete_state, ))) .await .map_err(handle_db_error)?; let response = Response::new(DeletePasswordUserStartResponse { session_id, opaque_login_response: server_response, }); Ok(response) } #[tracing::instrument(skip_all)] async fn delete_password_user_finish( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; let message = request.into_inner(); debug!("Attempting to finish deleting password user: {}", user_id); let Some(WorkflowInProgress::PasswordUserDeletion(state)) = self .db_client .get_workflow(message.session_id) .await .map_err(handle_db_error)? else { return Err(tonic::Status::not_found("session not found")); }; let mut server_login = state.opaque_server_login; server_login .finish(&message.opaque_login_upload) .map_err(protocol_error_to_grpc_status)?; self .db_client .delete_user(user_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] 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_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(SignedDeviceList::try_from) .collect::, _>>()?; let stringified_updates = device_list_updates .iter() .map(SignedDeviceList::as_json_string) .collect::, _>>()?; Ok(Response::new(GetDeviceListResponse { device_list_updates: stringified_updates, })) } #[tracing::instrument(skip_all)] async fn get_device_lists_for_users( &self, request: tonic::Request, ) -> Result, tonic::Status> { let PeersDeviceListsRequest { user_ids } = request.into_inner(); use crate::database::{DeviceListRow, DeviceRow}; async fn get_current_device_list_and_data( db_client: DatabaseClient, user_id: &str, ) -> Result<(Option, Vec), crate::error::Error> { let (list_result, data_result) = tokio::join!( db_client.get_current_device_list(user_id), db_client.get_current_devices(user_id) ); Ok((list_result?, data_result?)) } // do all fetches concurrently let mut fetch_tasks = tokio::task::JoinSet::new(); let mut device_lists = HashMap::with_capacity(user_ids.len()); let mut users_devices_platform_details = HashMap::with_capacity(user_ids.len()); for user_id in user_ids { let db_client = self.db_client.clone(); fetch_tasks.spawn(async move { let result = get_current_device_list_and_data(db_client, &user_id).await; (user_id, result) }); } while let Some(task_result) = fetch_tasks.join_next().await { match task_result { Ok((user_id, Ok((device_list, devices_data)))) => { let Some(device_list_row) = device_list else { warn!(user_id, "User has no device list, skipping!"); continue; }; let signed_list = SignedDeviceList::try_from(device_list_row)?; let serialized_list = signed_list.as_json_string()?; device_lists.insert(user_id.clone(), serialized_list); let devices_platform_details = devices_data .into_iter() .map(|data| (data.device_id, data.platform_details.into())) .collect(); users_devices_platform_details.insert( user_id, UserDevicesPlatformDetails { devices_platform_details, }, ); } Ok((user_id, Err(err))) => { error!( user_id, errorType = error_types::GRPC_SERVICES_LOG, "Failed fetching device list: {err}" ); // abort fetching other users fetch_tasks.abort_all(); return Err(handle_db_error(err)); } Err(join_error) => { error!( errorType = error_types::GRPC_SERVICES_LOG, "Failed to join device list task: {join_error}" ); fetch_tasks.abort_all(); return Err(Status::aborted("unexpected error")); } } } let response = PeersDeviceListsResponse { users_device_lists: device_lists, users_devices_platform_details, }; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn update_device_list( &self, request: tonic::Request, ) -> Result, tonic::Status> { 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, crate::device_list::validation::update_device_list_rpc_validator, ) .await .map_err(handle_db_error)?; Ok(Response::new(Empty {})) } #[tracing::instrument(skip_all)] async fn link_farcaster_account( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; let message = request.into_inner(); let mut get_farcaster_users_response = self .db_client .get_farcaster_users(vec![message.farcaster_id.clone()]) .await .map_err(handle_db_error)?; if get_farcaster_users_response.len() > 1 { error!( errorType = error_types::GRPC_SERVICES_LOG, "multiple users associated with the same Farcaster ID" ); return Err(Status::failed_precondition("cannot link Farcaster ID")); } if let Some(u) = get_farcaster_users_response.pop() { if u.0.user_id == user_id { return Ok(Response::new(Empty {})); } else { return Err(Status::already_exists( "farcaster ID already associated with different user", )); } } self .db_client .add_farcaster_id(user_id, message.farcaster_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn unlink_farcaster_account( &self, request: tonic::Request, ) -> Result, tonic::Status> { let (user_id, _) = get_user_and_device_id(&request)?; self .db_client .remove_farcaster_id(user_id) .await .map_err(handle_db_error)?; let response = Empty {}; Ok(Response::new(response)) } #[tracing::instrument(skip_all)] async fn find_user_identities( &self, request: tonic::Request, ) -> Result, tonic::Status> { let message = request.into_inner(); let results = self .db_client .find_db_user_identities(message.user_ids) .await .map_err(handle_db_error)?; let mapped_results = results .into_iter() .map(|(user_id, identifier)| (user_id, identifier.into())) .collect(); let response = UserIdentitiesResponse { identities: mapped_results, }; return Ok(Response::new(response)); } + + #[tracing::instrument(skip_all)] + async fn sync_platform_details( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + let (user_id, device_id) = get_user_and_device_id(&request)?; + let platform_metadata = get_platform_metadata(&request)?; + let platform_details = PlatformDetails::new(platform_metadata, None) + .map_err(|_| Status::invalid_argument("Invalid platform metadata"))?; + + self + .db_client + .update_device_platform_details(user_id, device_id, platform_details) + .await + .map_err(handle_db_error)?; + + Ok(Response::new(Empty {})) + } } #[allow(dead_code)] enum DeviceListItemKind { Any, Primary, Secondary, } impl AuthenticatedService { async fn verify_device_on_device_list( &self, user_id: &String, device_id: &String, device_kind: DeviceListItemKind, ) -> Result<(), tonic::Status> { let device_list = self .db_client .get_current_device_list(user_id) .await .map_err(|err| { error!( user_id, errorType = error_types::GRPC_SERVICES_LOG, "Failed fetching device list: {err}" ); handle_db_error(err) })?; let Some(device_list) = device_list else { error!( user_id, errorType = error_types::GRPC_SERVICES_LOG, "User has no device list!" ); return Err(Status::failed_precondition("no device list")); }; use DeviceListItemKind as DeviceKind; let device_on_list = match device_kind { DeviceKind::Any => device_list.has_device(device_id), DeviceKind::Primary => device_list.is_primary_device(device_id), DeviceKind::Secondary => device_list.has_secondary_device(device_id), }; if !device_on_list { debug!( "Device {} not on device list for user {}", device_id, user_id ); return Err(Status::permission_denied("device not on device list")); } Ok(()) } } #[derive(Clone, serde::Serialize, serde::Deserialize)] pub struct DeletePasswordUserInfo { pub opaque_server_login: comm_opaque2::server::Login, } fn construct_delete_password_user_info( opaque_server_login: comm_opaque2::server::Login, ) -> DeletePasswordUserInfo { DeletePasswordUserInfo { opaque_server_login, } } diff --git a/shared/protos/identity_auth.proto b/shared/protos/identity_auth.proto index 39b84188f..5c5beca32 100644 --- a/shared/protos/identity_auth.proto +++ b/shared/protos/identity_auth.proto @@ -1,291 +1,295 @@ syntax = "proto3"; import "identity_unauth.proto"; package identity.auth; // RPCs from a client (iOS, Android, or web) to identity service // // This service will assert authenticity of a device by verifying the access // token through an interceptor, thus avoiding the need to explicitly pass // the credentials on every request service IdentityClientService { /* X3DH actions */ // Replenish one-time preKeys rpc UploadOneTimeKeys(UploadOneTimeKeysRequest) returns (identity.unauth.Empty) {} // Rotate a device's prekey and prekey signature // Rotated for deniability of older messages rpc RefreshUserPrekeys(RefreshUserPrekeysRequest) returns (identity.unauth.Empty) {} // Called by clients to get all device keys associated with a user in order // to open a new channel of communication on any of their devices. // Specially, this will return the following per device: // - Identity keys (both Content and Notif Keys) // - Prekey (including prekey signature) // - One-time Prekey rpc GetOutboundKeysForUser(OutboundKeysForUserRequest) returns (OutboundKeysForUserResponse) {} // Called by receivers of a communication request. The reponse will return // identity keys (both content and notif keys) and related prekeys per device, // but will not contain one-time keys. Additionally, the response will contain // the other user's username. rpc GetInboundKeysForUser(InboundKeysForUserRequest) returns (InboundKeysForUserResponse) {} // Called by clients to get required keys for opening a connection // to a user's keyserver rpc GetKeyserverKeys(OutboundKeysForUserRequest) returns (KeyserverKeysResponse) {} /* Account actions */ // Called by user to update password and receive new access token rpc UpdateUserPasswordStart(UpdateUserPasswordStartRequest) returns (UpdateUserPasswordStartResponse) {} rpc UpdateUserPasswordFinish(UpdateUserPasswordFinishRequest) returns (identity.unauth.Empty) {} // Called by user to log out (clears device's keys and access token) rpc LogOutUser(identity.unauth.Empty) returns (identity.unauth.Empty) {} // Called by a ssecondary device to log out (clear its keys and access token) rpc LogOutSecondaryDevice(identity.unauth.Empty) returns (identity.unauth.Empty) {} // Called by a user to delete their own account rpc DeletePasswordUserStart(DeletePasswordUserStartRequest) returns (DeletePasswordUserStartResponse) {} rpc DeletePasswordUserFinish(DeletePasswordUserFinishRequest) returns (identity.unauth.Empty) {} rpc DeleteWalletUser(identity.unauth.Empty) returns (identity.unauth.Empty) {} /* Device list actions */ // Returns device list history rpc GetDeviceListForUser(GetDeviceListRequest) returns (GetDeviceListResponse) {} // Returns current device list for a set of users rpc GetDeviceListsForUsers (PeersDeviceListsRequest) returns (PeersDeviceListsResponse) {} rpc UpdateDeviceList(UpdateDeviceListRequest) returns (identity.unauth.Empty) {} /* Farcaster actions */ // Called by an existing user to link their Farcaster account rpc LinkFarcasterAccount(LinkFarcasterAccountRequest) returns (identity.unauth.Empty) {} // Called by an existing user to unlink their Farcaster account rpc UnlinkFarcasterAccount(identity.unauth.Empty) returns (identity.unauth.Empty) {} /* Miscellaneous actions */ rpc FindUserIdentities(UserIdentitiesRequest) returns (UserIdentitiesResponse) {} + + // Updates current device PlatformDetails stored on Identity Service. + // It doesn't require any input - all values are passed via request metadata. + rpc SyncPlatformDetails(identity.unauth.Empty) returns (identity.unauth.Empty) {} } // Helper types message EthereumIdentity { string wallet_address = 1; string siwe_message = 2; string siwe_signature = 3; } message Identity { // this is wallet address for Ethereum users string username = 1; optional EthereumIdentity eth_identity = 2; optional string farcaster_id = 3; } // UploadOneTimeKeys // As OPKs get exhausted, they need to be refreshed message UploadOneTimeKeysRequest { repeated string content_one_time_prekeys = 1; repeated string notif_one_time_prekeys = 2; } // RefreshUserPreKeys message RefreshUserPrekeysRequest { identity.unauth.Prekey new_content_prekeys = 1; identity.unauth.Prekey new_notif_prekeys = 2; } // Information needed when establishing communication to someone else's device message OutboundKeyInfo { identity.unauth.IdentityKeyInfo identity_info = 1; identity.unauth.Prekey content_prekey = 2; identity.unauth.Prekey notif_prekey = 3; optional string one_time_content_prekey = 4; optional string one_time_notif_prekey = 5; } message KeyserverKeysResponse { OutboundKeyInfo keyserver_info = 1; Identity identity = 2; identity.unauth.IdentityKeyInfo primary_device_identity_info = 3; } // GetOutboundKeysForUser message OutboundKeysForUserResponse { // Map is keyed on devices' public ed25519 key used for signing map devices = 1; } // Information needed by a device to establish communcation when responding // to a request. // The device receiving a request only needs the content key and prekey. message OutboundKeysForUserRequest { string user_id = 1; } // GetInboundKeysForUser message InboundKeyInfo { identity.unauth.IdentityKeyInfo identity_info = 1; identity.unauth.Prekey content_prekey = 2; identity.unauth.Prekey notif_prekey = 3; } message InboundKeysForUserResponse { // Map is keyed on devices' public ed25519 key used for signing map devices = 1; Identity identity = 2; } message InboundKeysForUserRequest { string user_id = 1; } // UpdateUserPassword // Request for updating a user, similar to registration but need a // access token to validate user before updating password message UpdateUserPasswordStartRequest { // Message sent to initiate PAKE registration (step 1) bytes opaque_registration_request = 1; } // Do a user registration, but overwrite the existing credentials // after validation of user message UpdateUserPasswordFinishRequest { // Identifier used to correlate start and finish request string session_id = 1; // Opaque client registration upload (step 3) bytes opaque_registration_upload = 2; } message UpdateUserPasswordStartResponse { // Identifier used to correlate start request with finish request string session_id = 1; bytes opaque_registration_response = 2; } // DeletePasswordUser // First user must log in message DeletePasswordUserStartRequest { // Message sent to initiate PAKE login bytes opaque_login_request = 1; } // If login is successful, the user's account will be deleted message DeletePasswordUserFinishRequest { // Identifier used to correlate requests in the same workflow string session_id = 1; bytes opaque_login_upload = 2; } message DeletePasswordUserStartResponse { // Identifier used to correlate requests in the same workflow string session_id = 1; bytes opaque_login_response = 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; } // GetDeviceListsForUsers // platform details for single device message PlatformDetails { identity.unauth.DeviceType device_type = 1; uint64 code_version = 2; optional uint64 state_version = 3; optional uint64 major_desktop_version = 4; } // platform details for user's devices message UserDevicesPlatformDetails { // keys are device IDs map devices_platform_details = 1; } message PeersDeviceListsRequest { repeated string user_ids = 1; } message PeersDeviceListsResponse { // keys are user_id // values are JSON-stringified device list payloads // (see GetDeviceListResponse message for payload structure) map users_device_lists = 1; // keys are user IDs map users_devices_platform_details = 2; } // UpdateDeviceListForUser message UpdateDeviceListRequest { // A stringified JSON object of the following format: // { // "rawDeviceList": JSON.stringify({ // "devices": [, ...] // "timestamp": , // }) // } string new_device_list = 1; } // LinkFarcasterAccount message LinkFarcasterAccountRequest { string farcaster_id = 1; } // FindUserIdentities message UserIdentitiesRequest { // user IDs for which we want to get the identity repeated string user_ids = 1; } message UserIdentitiesResponse { map identities = 1; } diff --git a/web/protobufs/identity-auth-client.cjs b/web/protobufs/identity-auth-client.cjs index d4ef4fd71..8377a93d1 100644 --- a/web/protobufs/identity-auth-client.cjs +++ b/web/protobufs/identity-auth-client.cjs @@ -1,1180 +1,1241 @@ /** * @fileoverview gRPC-Web generated client stub for identity.auth * @enhanceable * @public * @generated */ // Code generated by protoc-gen-grpc-web. DO NOT EDIT. // versions: // protoc-gen-grpc-web v1.4.2 // protoc v3.21.12 // source: identity_auth.proto /* eslint-disable */ // @ts-nocheck const grpc = {}; grpc.web = require('grpc-web'); var identity_unauth_pb = require('./identity-unauth-structs.cjs') const proto = {}; proto.identity = {}; proto.identity.auth = require('./identity-auth-structs.cjs'); /** * @param {string} hostname * @param {?Object} credentials * @param {?grpc.web.ClientOptions} options * @constructor * @struct * @final */ proto.identity.auth.IdentityClientServiceClient = function(hostname, credentials, options) { if (!options) options = {}; options.format = 'text'; /** * @private @const {!grpc.web.GrpcWebClientBase} The client */ this.client_ = new grpc.web.GrpcWebClientBase(options); /** * @private @const {string} The hostname */ this.hostname_ = hostname.replace(/\/+$/, ''); }; /** * @param {string} hostname * @param {?Object} credentials * @param {?grpc.web.ClientOptions} options * @constructor * @struct * @final */ proto.identity.auth.IdentityClientServicePromiseClient = function(hostname, credentials, options) { if (!options) options = {}; options.format = 'text'; /** * @private @const {!grpc.web.GrpcWebClientBase} The client */ this.client_ = new grpc.web.GrpcWebClientBase(options); /** * @private @const {string} The hostname */ this.hostname_ = hostname.replace(/\/+$/, ''); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.UploadOneTimeKeysRequest, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_UploadOneTimeKeys = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/UploadOneTimeKeys', grpc.web.MethodType.UNARY, proto.identity.auth.UploadOneTimeKeysRequest, identity_unauth_pb.Empty, /** * @param {!proto.identity.auth.UploadOneTimeKeysRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.auth.UploadOneTimeKeysRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.uploadOneTimeKeys = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/UploadOneTimeKeys', request, metadata || {}, methodDescriptor_IdentityClientService_UploadOneTimeKeys, callback); }; /** * @param {!proto.identity.auth.UploadOneTimeKeysRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.uploadOneTimeKeys = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/UploadOneTimeKeys', request, metadata || {}, methodDescriptor_IdentityClientService_UploadOneTimeKeys); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.RefreshUserPrekeysRequest, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_RefreshUserPrekeys = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/RefreshUserPrekeys', grpc.web.MethodType.UNARY, proto.identity.auth.RefreshUserPrekeysRequest, identity_unauth_pb.Empty, /** * @param {!proto.identity.auth.RefreshUserPrekeysRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.auth.RefreshUserPrekeysRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.refreshUserPrekeys = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/RefreshUserPrekeys', request, metadata || {}, methodDescriptor_IdentityClientService_RefreshUserPrekeys, callback); }; /** * @param {!proto.identity.auth.RefreshUserPrekeysRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.refreshUserPrekeys = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/RefreshUserPrekeys', request, metadata || {}, methodDescriptor_IdentityClientService_RefreshUserPrekeys); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.OutboundKeysForUserRequest, * !proto.identity.auth.OutboundKeysForUserResponse>} */ const methodDescriptor_IdentityClientService_GetOutboundKeysForUser = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/GetOutboundKeysForUser', grpc.web.MethodType.UNARY, proto.identity.auth.OutboundKeysForUserRequest, proto.identity.auth.OutboundKeysForUserResponse, /** * @param {!proto.identity.auth.OutboundKeysForUserRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, proto.identity.auth.OutboundKeysForUserResponse.deserializeBinary ); /** * @param {!proto.identity.auth.OutboundKeysForUserRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.auth.OutboundKeysForUserResponse)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.getOutboundKeysForUser = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/GetOutboundKeysForUser', request, metadata || {}, methodDescriptor_IdentityClientService_GetOutboundKeysForUser, callback); }; /** * @param {!proto.identity.auth.OutboundKeysForUserRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.getOutboundKeysForUser = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/GetOutboundKeysForUser', request, metadata || {}, methodDescriptor_IdentityClientService_GetOutboundKeysForUser); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.InboundKeysForUserRequest, * !proto.identity.auth.InboundKeysForUserResponse>} */ const methodDescriptor_IdentityClientService_GetInboundKeysForUser = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/GetInboundKeysForUser', grpc.web.MethodType.UNARY, proto.identity.auth.InboundKeysForUserRequest, proto.identity.auth.InboundKeysForUserResponse, /** * @param {!proto.identity.auth.InboundKeysForUserRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, proto.identity.auth.InboundKeysForUserResponse.deserializeBinary ); /** * @param {!proto.identity.auth.InboundKeysForUserRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.auth.InboundKeysForUserResponse)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.getInboundKeysForUser = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/GetInboundKeysForUser', request, metadata || {}, methodDescriptor_IdentityClientService_GetInboundKeysForUser, callback); }; /** * @param {!proto.identity.auth.InboundKeysForUserRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.getInboundKeysForUser = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/GetInboundKeysForUser', request, metadata || {}, methodDescriptor_IdentityClientService_GetInboundKeysForUser); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.OutboundKeysForUserRequest, * !proto.identity.auth.KeyserverKeysResponse>} */ const methodDescriptor_IdentityClientService_GetKeyserverKeys = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/GetKeyserverKeys', grpc.web.MethodType.UNARY, proto.identity.auth.OutboundKeysForUserRequest, proto.identity.auth.KeyserverKeysResponse, /** * @param {!proto.identity.auth.OutboundKeysForUserRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, proto.identity.auth.KeyserverKeysResponse.deserializeBinary ); /** * @param {!proto.identity.auth.OutboundKeysForUserRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.auth.KeyserverKeysResponse)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.getKeyserverKeys = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/GetKeyserverKeys', request, metadata || {}, methodDescriptor_IdentityClientService_GetKeyserverKeys, callback); }; /** * @param {!proto.identity.auth.OutboundKeysForUserRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.getKeyserverKeys = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/GetKeyserverKeys', request, metadata || {}, methodDescriptor_IdentityClientService_GetKeyserverKeys); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.UpdateUserPasswordStartRequest, * !proto.identity.auth.UpdateUserPasswordStartResponse>} */ const methodDescriptor_IdentityClientService_UpdateUserPasswordStart = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/UpdateUserPasswordStart', grpc.web.MethodType.UNARY, proto.identity.auth.UpdateUserPasswordStartRequest, proto.identity.auth.UpdateUserPasswordStartResponse, /** * @param {!proto.identity.auth.UpdateUserPasswordStartRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, proto.identity.auth.UpdateUserPasswordStartResponse.deserializeBinary ); /** * @param {!proto.identity.auth.UpdateUserPasswordStartRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.auth.UpdateUserPasswordStartResponse)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.updateUserPasswordStart = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/UpdateUserPasswordStart', request, metadata || {}, methodDescriptor_IdentityClientService_UpdateUserPasswordStart, callback); }; /** * @param {!proto.identity.auth.UpdateUserPasswordStartRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.updateUserPasswordStart = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/UpdateUserPasswordStart', request, metadata || {}, methodDescriptor_IdentityClientService_UpdateUserPasswordStart); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.UpdateUserPasswordFinishRequest, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_UpdateUserPasswordFinish = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/UpdateUserPasswordFinish', grpc.web.MethodType.UNARY, proto.identity.auth.UpdateUserPasswordFinishRequest, identity_unauth_pb.Empty, /** * @param {!proto.identity.auth.UpdateUserPasswordFinishRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.auth.UpdateUserPasswordFinishRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.updateUserPasswordFinish = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/UpdateUserPasswordFinish', request, metadata || {}, methodDescriptor_IdentityClientService_UpdateUserPasswordFinish, callback); }; /** * @param {!proto.identity.auth.UpdateUserPasswordFinishRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.updateUserPasswordFinish = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/UpdateUserPasswordFinish', request, metadata || {}, methodDescriptor_IdentityClientService_UpdateUserPasswordFinish); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.unauth.Empty, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_LogOutUser = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/LogOutUser', grpc.web.MethodType.UNARY, identity_unauth_pb.Empty, identity_unauth_pb.Empty, /** * @param {!proto.identity.unauth.Empty} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.unauth.Empty} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.logOutUser = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/LogOutUser', request, metadata || {}, methodDescriptor_IdentityClientService_LogOutUser, callback); }; /** * @param {!proto.identity.unauth.Empty} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.logOutUser = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/LogOutUser', request, metadata || {}, methodDescriptor_IdentityClientService_LogOutUser); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.unauth.Empty, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_LogOutSecondaryDevice = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/LogOutSecondaryDevice', grpc.web.MethodType.UNARY, identity_unauth_pb.Empty, identity_unauth_pb.Empty, /** * @param {!proto.identity.unauth.Empty} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.unauth.Empty} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.logOutSecondaryDevice = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/LogOutSecondaryDevice', request, metadata || {}, methodDescriptor_IdentityClientService_LogOutSecondaryDevice, callback); }; /** * @param {!proto.identity.unauth.Empty} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.logOutSecondaryDevice = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/LogOutSecondaryDevice', request, metadata || {}, methodDescriptor_IdentityClientService_LogOutSecondaryDevice); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.DeletePasswordUserStartRequest, * !proto.identity.auth.DeletePasswordUserStartResponse>} */ const methodDescriptor_IdentityClientService_DeletePasswordUserStart = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/DeletePasswordUserStart', grpc.web.MethodType.UNARY, proto.identity.auth.DeletePasswordUserStartRequest, proto.identity.auth.DeletePasswordUserStartResponse, /** * @param {!proto.identity.auth.DeletePasswordUserStartRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, proto.identity.auth.DeletePasswordUserStartResponse.deserializeBinary ); /** * @param {!proto.identity.auth.DeletePasswordUserStartRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.auth.DeletePasswordUserStartResponse)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.deletePasswordUserStart = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/DeletePasswordUserStart', request, metadata || {}, methodDescriptor_IdentityClientService_DeletePasswordUserStart, callback); }; /** * @param {!proto.identity.auth.DeletePasswordUserStartRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.deletePasswordUserStart = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/DeletePasswordUserStart', request, metadata || {}, methodDescriptor_IdentityClientService_DeletePasswordUserStart); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.DeletePasswordUserFinishRequest, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_DeletePasswordUserFinish = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/DeletePasswordUserFinish', grpc.web.MethodType.UNARY, proto.identity.auth.DeletePasswordUserFinishRequest, identity_unauth_pb.Empty, /** * @param {!proto.identity.auth.DeletePasswordUserFinishRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.auth.DeletePasswordUserFinishRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.deletePasswordUserFinish = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/DeletePasswordUserFinish', request, metadata || {}, methodDescriptor_IdentityClientService_DeletePasswordUserFinish, callback); }; /** * @param {!proto.identity.auth.DeletePasswordUserFinishRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.deletePasswordUserFinish = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/DeletePasswordUserFinish', request, metadata || {}, methodDescriptor_IdentityClientService_DeletePasswordUserFinish); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.unauth.Empty, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_DeleteWalletUser = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/DeleteWalletUser', grpc.web.MethodType.UNARY, identity_unauth_pb.Empty, identity_unauth_pb.Empty, /** * @param {!proto.identity.unauth.Empty} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.unauth.Empty} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.deleteWalletUser = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/DeleteWalletUser', request, metadata || {}, methodDescriptor_IdentityClientService_DeleteWalletUser, callback); }; /** * @param {!proto.identity.unauth.Empty} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.deleteWalletUser = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/DeleteWalletUser', request, metadata || {}, methodDescriptor_IdentityClientService_DeleteWalletUser); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.GetDeviceListRequest, * !proto.identity.auth.GetDeviceListResponse>} */ const methodDescriptor_IdentityClientService_GetDeviceListForUser = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/GetDeviceListForUser', grpc.web.MethodType.UNARY, proto.identity.auth.GetDeviceListRequest, proto.identity.auth.GetDeviceListResponse, /** * @param {!proto.identity.auth.GetDeviceListRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, proto.identity.auth.GetDeviceListResponse.deserializeBinary ); /** * @param {!proto.identity.auth.GetDeviceListRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.auth.GetDeviceListResponse)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.getDeviceListForUser = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/GetDeviceListForUser', request, metadata || {}, methodDescriptor_IdentityClientService_GetDeviceListForUser, callback); }; /** * @param {!proto.identity.auth.GetDeviceListRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.getDeviceListForUser = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/GetDeviceListForUser', request, metadata || {}, methodDescriptor_IdentityClientService_GetDeviceListForUser); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.PeersDeviceListsRequest, * !proto.identity.auth.PeersDeviceListsResponse>} */ const methodDescriptor_IdentityClientService_GetDeviceListsForUsers = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/GetDeviceListsForUsers', grpc.web.MethodType.UNARY, proto.identity.auth.PeersDeviceListsRequest, proto.identity.auth.PeersDeviceListsResponse, /** * @param {!proto.identity.auth.PeersDeviceListsRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, proto.identity.auth.PeersDeviceListsResponse.deserializeBinary ); /** * @param {!proto.identity.auth.PeersDeviceListsRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.auth.PeersDeviceListsResponse)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.getDeviceListsForUsers = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/GetDeviceListsForUsers', request, metadata || {}, methodDescriptor_IdentityClientService_GetDeviceListsForUsers, callback); }; /** * @param {!proto.identity.auth.PeersDeviceListsRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.getDeviceListsForUsers = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/GetDeviceListsForUsers', request, metadata || {}, methodDescriptor_IdentityClientService_GetDeviceListsForUsers); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.UpdateDeviceListRequest, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_UpdateDeviceList = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/UpdateDeviceList', grpc.web.MethodType.UNARY, proto.identity.auth.UpdateDeviceListRequest, identity_unauth_pb.Empty, /** * @param {!proto.identity.auth.UpdateDeviceListRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.auth.UpdateDeviceListRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.updateDeviceList = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/UpdateDeviceList', request, metadata || {}, methodDescriptor_IdentityClientService_UpdateDeviceList, callback); }; /** * @param {!proto.identity.auth.UpdateDeviceListRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.updateDeviceList = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/UpdateDeviceList', request, metadata || {}, methodDescriptor_IdentityClientService_UpdateDeviceList); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.LinkFarcasterAccountRequest, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_LinkFarcasterAccount = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/LinkFarcasterAccount', grpc.web.MethodType.UNARY, proto.identity.auth.LinkFarcasterAccountRequest, identity_unauth_pb.Empty, /** * @param {!proto.identity.auth.LinkFarcasterAccountRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.auth.LinkFarcasterAccountRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.linkFarcasterAccount = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/LinkFarcasterAccount', request, metadata || {}, methodDescriptor_IdentityClientService_LinkFarcasterAccount, callback); }; /** * @param {!proto.identity.auth.LinkFarcasterAccountRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.linkFarcasterAccount = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/LinkFarcasterAccount', request, metadata || {}, methodDescriptor_IdentityClientService_LinkFarcasterAccount); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.unauth.Empty, * !proto.identity.unauth.Empty>} */ const methodDescriptor_IdentityClientService_UnlinkFarcasterAccount = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/UnlinkFarcasterAccount', grpc.web.MethodType.UNARY, identity_unauth_pb.Empty, identity_unauth_pb.Empty, /** * @param {!proto.identity.unauth.Empty} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, identity_unauth_pb.Empty.deserializeBinary ); /** * @param {!proto.identity.unauth.Empty} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.unlinkFarcasterAccount = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/UnlinkFarcasterAccount', request, metadata || {}, methodDescriptor_IdentityClientService_UnlinkFarcasterAccount, callback); }; /** * @param {!proto.identity.unauth.Empty} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.unlinkFarcasterAccount = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/UnlinkFarcasterAccount', request, metadata || {}, methodDescriptor_IdentityClientService_UnlinkFarcasterAccount); }; /** * @const * @type {!grpc.web.MethodDescriptor< * !proto.identity.auth.UserIdentitiesRequest, * !proto.identity.auth.UserIdentitiesResponse>} */ const methodDescriptor_IdentityClientService_FindUserIdentities = new grpc.web.MethodDescriptor( '/identity.auth.IdentityClientService/FindUserIdentities', grpc.web.MethodType.UNARY, proto.identity.auth.UserIdentitiesRequest, proto.identity.auth.UserIdentitiesResponse, /** * @param {!proto.identity.auth.UserIdentitiesRequest} request * @return {!Uint8Array} */ function(request) { return request.serializeBinary(); }, proto.identity.auth.UserIdentitiesResponse.deserializeBinary ); /** * @param {!proto.identity.auth.UserIdentitiesRequest} request The * request proto * @param {?Object} metadata User defined * call metadata * @param {function(?grpc.web.RpcError, ?proto.identity.auth.UserIdentitiesResponse)} * callback The callback function(error, response) * @return {!grpc.web.ClientReadableStream|undefined} * The XHR Node Readable Stream */ proto.identity.auth.IdentityClientServiceClient.prototype.findUserIdentities = function(request, metadata, callback) { return this.client_.rpcCall(this.hostname_ + '/identity.auth.IdentityClientService/FindUserIdentities', request, metadata || {}, methodDescriptor_IdentityClientService_FindUserIdentities, callback); }; /** * @param {!proto.identity.auth.UserIdentitiesRequest} request The * request proto * @param {?Object=} metadata User defined * call metadata * @return {!Promise} * Promise that resolves to the response */ proto.identity.auth.IdentityClientServicePromiseClient.prototype.findUserIdentities = function(request, metadata) { return this.client_.unaryCall(this.hostname_ + '/identity.auth.IdentityClientService/FindUserIdentities', request, metadata || {}, methodDescriptor_IdentityClientService_FindUserIdentities); }; +/** + * @const + * @type {!grpc.web.MethodDescriptor< + * !proto.identity.unauth.Empty, + * !proto.identity.unauth.Empty>} + */ +const methodDescriptor_IdentityClientService_SyncPlatformDetails = new grpc.web.MethodDescriptor( + '/identity.auth.IdentityClientService/SyncPlatformDetails', + grpc.web.MethodType.UNARY, + identity_unauth_pb.Empty, + identity_unauth_pb.Empty, + /** + * @param {!proto.identity.unauth.Empty} request + * @return {!Uint8Array} + */ + function(request) { + return request.serializeBinary(); + }, + identity_unauth_pb.Empty.deserializeBinary +); + + +/** + * @param {!proto.identity.unauth.Empty} request The + * request proto + * @param {?Object} metadata User defined + * call metadata + * @param {function(?grpc.web.RpcError, ?proto.identity.unauth.Empty)} + * callback The callback function(error, response) + * @return {!grpc.web.ClientReadableStream|undefined} + * The XHR Node Readable Stream + */ +proto.identity.auth.IdentityClientServiceClient.prototype.syncPlatformDetails = + function(request, metadata, callback) { + return this.client_.rpcCall(this.hostname_ + + '/identity.auth.IdentityClientService/SyncPlatformDetails', + request, + metadata || {}, + methodDescriptor_IdentityClientService_SyncPlatformDetails, + callback); +}; + + +/** + * @param {!proto.identity.unauth.Empty} request The + * request proto + * @param {?Object=} metadata User defined + * call metadata + * @return {!Promise} + * Promise that resolves to the response + */ +proto.identity.auth.IdentityClientServicePromiseClient.prototype.syncPlatformDetails = + function(request, metadata) { + return this.client_.unaryCall(this.hostname_ + + '/identity.auth.IdentityClientService/SyncPlatformDetails', + request, + metadata || {}, + methodDescriptor_IdentityClientService_SyncPlatformDetails); +}; + + module.exports = proto.identity.auth; diff --git a/web/protobufs/identity-auth-client.cjs.flow b/web/protobufs/identity-auth-client.cjs.flow index dbf7585c8..1b4a27742 100644 --- a/web/protobufs/identity-auth-client.cjs.flow +++ b/web/protobufs/identity-auth-client.cjs.flow @@ -1,236 +1,249 @@ // @flow import * as grpcWeb from 'grpc-web'; import * as identityAuthStructs from './identity-auth-structs.cjs'; import * as identityStructs from './identity-unauth-structs.cjs'; declare export class IdentityClientServiceClient { constructor (hostname: string, credentials?: null | { +[index: string]: string; }, options?: null | { +[index: string]: any; }): void; uploadOneTimeKeys( request: identityAuthStructs.UploadOneTimeKeysRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; refreshUserPrekeys( request: identityAuthStructs.RefreshUserPrekeysRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; getOutboundKeysForUser( request: identityAuthStructs.OutboundKeysForUserRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityAuthStructs.OutboundKeysForUserResponse) => void ): grpcWeb.ClientReadableStream; getInboundKeysForUser( request: identityAuthStructs.InboundKeysForUserRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityAuthStructs.InboundKeysForUserResponse) => void ): grpcWeb.ClientReadableStream; getKeyserverKeys( request: identityAuthStructs.OutboundKeysForUserRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityAuthStructs.KeyserverKeysResponse) => void ): grpcWeb.ClientReadableStream; updateUserPasswordStart( request: identityAuthStructs.UpdateUserPasswordStartRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityAuthStructs.UpdateUserPasswordStartResponse) => void ): grpcWeb.ClientReadableStream; updateUserPasswordFinish( request: identityAuthStructs.UpdateUserPasswordFinishRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; logOutUser( request: identityStructs.Empty, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; logOutSecondaryDevice( request: identityStructs.Empty, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; deletePasswordUserStart( request: identityAuthStructs.DeletePasswordUserStartRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityAuthStructs.DeletePasswordUserStartResponse) => void ): grpcWeb.ClientReadableStream; deletePasswordUserFinish( request: identityAuthStructs.DeletePasswordUserFinishRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; deleteWalletUser( request: identityStructs.Empty, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; getDeviceListForUser( request: identityAuthStructs.GetDeviceListRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityAuthStructs.GetDeviceListResponse) => void ): grpcWeb.ClientReadableStream; getDeviceListsForUsers( request: identityAuthStructs.PeersDeviceListsRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityAuthStructs.PeersDeviceListsResponse) => void ): grpcWeb.ClientReadableStream; updateDeviceList( request: identityAuthStructs.UpdateDeviceListRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; linkFarcasterAccount( request: identityAuthStructs.LinkFarcasterAccountRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; unlinkFarcasterAccount( request: identityStructs.Empty, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityStructs.Empty) => void ): grpcWeb.ClientReadableStream; findUserIdentities( request: identityAuthStructs.UserIdentitiesRequest, metadata: grpcWeb.Metadata | void, callback: (err: grpcWeb.RpcError, response: identityAuthStructs.UserIdentitiesResponse) => void ): grpcWeb.ClientReadableStream; + + syncPlatformDetails( + request: identityStructs.Empty, + metadata: grpcWeb.Metadata | void, + callback: (err: grpcWeb.RpcError, + response: identityStructs.Empty) => void + ): grpcWeb.ClientReadableStream; } declare export class IdentityClientServicePromiseClient { constructor (hostname: string, credentials?: null | { +[index: string]: string; }, options?: null | { +[index: string]: any; }): void; uploadOneTimeKeys( request: identityAuthStructs.UploadOneTimeKeysRequest, metadata?: grpcWeb.Metadata ): Promise; refreshUserPrekeys( request: identityAuthStructs.RefreshUserPrekeysRequest, metadata?: grpcWeb.Metadata ): Promise; getOutboundKeysForUser( request: identityAuthStructs.OutboundKeysForUserRequest, metadata?: grpcWeb.Metadata ): Promise; getInboundKeysForUser( request: identityAuthStructs.InboundKeysForUserRequest, metadata?: grpcWeb.Metadata ): Promise; getKeyserverKeys( request: identityAuthStructs.OutboundKeysForUserRequest, metadata?: grpcWeb.Metadata ): Promise; updateUserPasswordStart( request: identityAuthStructs.UpdateUserPasswordStartRequest, metadata?: grpcWeb.Metadata ): Promise; updateUserPasswordFinish( request: identityAuthStructs.UpdateUserPasswordFinishRequest, metadata?: grpcWeb.Metadata ): Promise; logOutUser( request: identityStructs.Empty, metadata?: grpcWeb.Metadata ): Promise; logOutSecondaryDevice( request: identityStructs.Empty, metadata?: grpcWeb.Metadata ): Promise; deletePasswordUserStart( request: identityAuthStructs.DeletePasswordUserStartRequest, metadata?: grpcWeb.Metadata ): Promise; deletePasswordUserFinish( request: identityAuthStructs.DeletePasswordUserFinishRequest, metadata?: grpcWeb.Metadata ): Promise; deleteWalletUser( request: identityStructs.Empty, metadata?: grpcWeb.Metadata ): Promise; getDeviceListForUser( request: identityAuthStructs.GetDeviceListRequest, metadata?: grpcWeb.Metadata ): Promise; getDeviceListsForUsers( request: identityAuthStructs.PeersDeviceListsRequest, metadata?: grpcWeb.Metadata ): Promise; updateDeviceList( request: identityAuthStructs.UpdateDeviceListRequest, metadata?: grpcWeb.Metadata ): Promise; linkFarcasterAccount( request: identityAuthStructs.LinkFarcasterAccountRequest, metadata?: grpcWeb.Metadata ): Promise; unlinkFarcasterAccount( request: identityStructs.Empty, metadata?: grpcWeb.Metadata ): Promise; findUserIdentities( request: identityAuthStructs.UserIdentitiesRequest, metadata?: grpcWeb.Metadata ): Promise; + + syncPlatformDetails( + request: identityStructs.Empty, + metadata?: grpcWeb.Metadata + ): Promise; + }