diff --git a/services/identity/src/constants.rs b/services/identity/src/constants.rs index b6202bac7..03f0fc642 100644 --- a/services/identity/src/constants.rs +++ b/services/identity/src/constants.rs @@ -1,295 +1,300 @@ use tokio::time::Duration; // Secrets pub const SECRETS_DIRECTORY: &str = "secrets"; pub const SECRETS_SETUP_FILE: &str = "server_setup.txt"; // DynamoDB // User table information, supporting opaque_ke 2.0 and X3DH information // Users can sign in either through username+password or Eth wallet. // // This structure should be aligned with the messages defined in // shared/protos/identity_unauthenticated.proto // // Structure for a user should be: // { // userID: String, // opaqueRegistrationData: Option, // username: Option, // walletAddress: Option, // devices: HashMap // } // // A device is defined as: // { // deviceType: String, # client or keyserver // keyPayload: String, // keyPayloadSignature: String, // identityPreKey: String, // identityPreKeySignature: String, // identityOneTimeKeys: Vec, // notifPreKey: String, // notifPreKeySignature: String, // notifOneTimeKeys: Vec, // socialProof: Option // } // } // // Additional context: // "devices" uses the signing public identity key of the device as a key for the devices map // "keyPayload" is a JSON encoded string containing identity and notif keys (both signature and verification) // if "deviceType" == "keyserver", then the device will not have any notif key information pub const USERS_TABLE: &str = "identity-users"; pub const USERS_TABLE_PARTITION_KEY: &str = "userID"; pub const USERS_TABLE_REGISTRATION_ATTRIBUTE: &str = "opaqueRegistrationData"; pub const USERS_TABLE_USERNAME_ATTRIBUTE: &str = "username"; pub const USERS_TABLE_DEVICES_MAP_DEVICE_TYPE_ATTRIBUTE_NAME: &str = "deviceType"; pub const USERS_TABLE_WALLET_ADDRESS_ATTRIBUTE: &str = "walletAddress"; pub const USERS_TABLE_SOCIAL_PROOF_ATTRIBUTE_NAME: &str = "socialProof"; pub const USERS_TABLE_DEVICELIST_TIMESTAMP_ATTRIBUTE_NAME: &str = "deviceListTimestamp"; pub const USERS_TABLE_FARCASTER_ID_ATTRIBUTE_NAME: &str = "farcasterID"; pub const USERS_TABLE_USERNAME_INDEX: &str = "username-index"; pub const USERS_TABLE_WALLET_ADDRESS_INDEX: &str = "walletAddress-index"; pub const USERS_TABLE_FARCASTER_ID_INDEX: &str = "farcasterID-index"; pub mod token_table { pub const NAME: &str = "identity-tokens"; pub const PARTITION_KEY: &str = "userID"; pub const SORT_KEY: &str = "signingPublicKey"; pub const ATTR_CREATED: &str = "created"; pub const ATTR_AUTH_TYPE: &str = "authType"; pub const ATTR_VALID: &str = "valid"; pub const ATTR_TOKEN: &str = "token"; } pub const NONCE_TABLE: &str = "identity-nonces"; pub const NONCE_TABLE_PARTITION_KEY: &str = "nonce"; pub const NONCE_TABLE_CREATED_ATTRIBUTE: &str = "created"; pub const NONCE_TABLE_EXPIRATION_TIME_ATTRIBUTE: &str = "expirationTime"; pub const NONCE_TABLE_EXPIRATION_TIME_UNIX_ATTRIBUTE: &str = "expirationTimeUnix"; pub const WORKFLOWS_IN_PROGRESS_TABLE: &str = "identity-workflows-in-progress"; pub const WORKFLOWS_IN_PROGRESS_PARTITION_KEY: &str = "id"; pub const WORKFLOWS_IN_PROGRESS_WORKFLOW_ATTRIBUTE: &str = "workflow"; pub const WORKFLOWS_IN_PROGRESS_TABLE_EXPIRATION_TIME_UNIX_ATTRIBUTE: &str = "expirationTimeUnix"; // Usernames reserved because they exist in Ashoat's keyserver already pub const RESERVED_USERNAMES_TABLE: &str = "identity-reserved-usernames"; pub const RESERVED_USERNAMES_TABLE_PARTITION_KEY: &str = "username"; pub const RESERVED_USERNAMES_TABLE_USER_ID_ATTRIBUTE: &str = "userID"; // Users table social proof attribute pub const SOCIAL_PROOF_MESSAGE_ATTRIBUTE: &str = "siweMessage"; pub const SOCIAL_PROOF_SIGNATURE_ATTRIBUTE: &str = "siweSignature"; pub mod devices_table { /// table name pub const NAME: &str = "identity-devices"; pub const TIMESTAMP_INDEX_NAME: &str = "deviceList-timestamp-index"; pub const DEVICE_ID_INDEX_NAME: &str = "deviceID-index"; /// partition key pub const ATTR_USER_ID: &str = "userID"; /// sort key pub const ATTR_ITEM_ID: &str = "itemID"; // itemID prefixes (one shouldn't be a prefix of the other) pub const DEVICE_ITEM_KEY_PREFIX: &str = "device-"; pub const DEVICE_LIST_KEY_PREFIX: &str = "devicelist-"; // device-specific attrs pub const ATTR_DEVICE_KEY_INFO: &str = "deviceKeyInfo"; pub const ATTR_CONTENT_PREKEY: &str = "contentPreKey"; pub const ATTR_NOTIF_PREKEY: &str = "notifPreKey"; pub const ATTR_PLATFORM_DETAILS: &str = "platformDetails"; pub const ATTR_LOGIN_TIME: &str = "loginTime"; // IdentityKeyInfo constants pub const ATTR_KEY_PAYLOAD: &str = "keyPayload"; pub const ATTR_KEY_PAYLOAD_SIGNATURE: &str = "keyPayloadSignature"; // PreKey constants pub const ATTR_PREKEY: &str = "preKey"; pub const ATTR_PREKEY_SIGNATURE: &str = "preKeySignature"; // PlatformDetails constants pub const ATTR_DEVICE_TYPE: &str = "deviceType"; pub const ATTR_CODE_VERSION: &str = "codeVersion"; pub const ATTR_STATE_VERSION: &str = "stateVersion"; pub const ATTR_MAJOR_DESKTOP_VERSION: &str = "majorDesktopVersion"; // device-list-specific attrs pub const ATTR_TIMESTAMP: &str = "timestamp"; pub const ATTR_DEVICE_IDS: &str = "deviceIDs"; pub const ATTR_CURRENT_SIGNATURE: &str = "curPrimarySignature"; pub const ATTR_LAST_SIGNATURE: &str = "lastPrimarySignature"; // one-time key constants pub const ATTR_CONTENT_OTK_COUNT: &str = "contentOTKCount"; pub const ATTR_NOTIF_OTK_COUNT: &str = "notifOTKCount"; // deprecated attributes pub const OLD_ATTR_DEVICE_TYPE: &str = "deviceType"; pub const OLD_ATTR_CODE_VERSION: &str = "codeVersion"; } // One time keys table, which need to exist in their own table to ensure // atomicity of additions and removals pub mod one_time_keys_table { pub const NAME: &str = "identity-one-time-keys"; pub const PARTITION_KEY: &str = "userID#deviceID#olmAccount"; pub const SORT_KEY: &str = "timestamp#keyNumber"; pub const ATTR_ONE_TIME_KEY: &str = "oneTimeKey"; } // Tokio pub const MPSC_CHANNEL_BUFFER_CAPACITY: usize = 1; pub const IDENTITY_SERVICE_SOCKET_ADDR: &str = "[::]:50054"; pub const IDENTITY_SERVICE_WEBSOCKET_ADDR: &str = "[::]:51004"; pub const SOCKET_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(3); // Token pub const ACCESS_TOKEN_LENGTH: usize = 512; // Temporary config pub const AUTH_TOKEN: &str = "COMM_IDENTITY_SERVICE_AUTH_TOKEN"; pub const KEYSERVER_PUBLIC_KEY: &str = "KEYSERVER_PUBLIC_KEY"; // Nonce pub const NONCE_LENGTH: usize = 17; pub const NONCE_TTL_DURATION: Duration = Duration::from_secs(120); // seconds // Device list pub const DEVICE_LIST_TIMESTAMP_VALID_FOR: Duration = Duration::from_secs(300); // Workflows in progress pub const WORKFLOWS_IN_PROGRESS_TTL_DURATION: Duration = Duration::from_secs(120); // Identity pub const DEFAULT_IDENTITY_ENDPOINT: &str = "http://localhost:50054"; // LocalStack pub const LOCALSTACK_ENDPOINT: &str = "LOCALSTACK_ENDPOINT"; // OPAQUE Server Setup pub const OPAQUE_SERVER_SETUP: &str = "OPAQUE_SERVER_SETUP"; // Identity Search pub const OPENSEARCH_ENDPOINT: &str = "OPENSEARCH_ENDPOINT"; pub const DEFAULT_OPENSEARCH_ENDPOINT: &str = "identity-search-domain.us-east-2.opensearch.localhost.localstack.cloud:4566"; pub const IDENTITY_SEARCH_INDEX: &str = "users"; pub const IDENTITY_SEARCH_RESULT_SIZE: u32 = 20; // Log Error Types pub mod error_types { pub const GENERIC_DB_LOG: &str = "DB Error"; pub const OTK_DB_LOG: &str = "One-time Key DB Error"; pub const DEVICE_LIST_DB_LOG: &str = "Device List DB Error"; pub const TOKEN_DB_LOG: &str = "Token DB Error"; pub const FARCASTER_DB_LOG: &str = "Farcaster DB Error"; pub const SYNC_LOG: &str = "Sync Error"; pub const SEARCH_LOG: &str = "Search Error"; pub const SIWE_LOG: &str = "SIWE Error"; pub const GRPC_SERVICES_LOG: &str = "gRPC Services Error"; pub const TUNNELBROKER_LOG: &str = "Tunnelbroker Error"; pub const HTTP_LOG: &str = "HTTP Error"; } +// Tonic Status Messages +pub mod tonic_status_messages { + pub const UNEXPECTED_MESSAGE_DATA: &str = "unexpected_message_data"; +} + // Tunnelbroker pub const TUNNELBROKER_GRPC_ENDPOINT: &str = "TUNNELBROKER_GRPC_ENDPOINT"; pub const DEFAULT_TUNNELBROKER_ENDPOINT: &str = "http://localhost:50051"; // X3DH key management // Threshold for requesting more one_time keys pub const ONE_TIME_KEY_MINIMUM_THRESHOLD: usize = 5; // Number of keys to be refreshed when below the threshold pub const ONE_TIME_KEY_REFRESH_NUMBER: u32 = 5; // Minimum supported code versions pub const MIN_SUPPORTED_NATIVE_VERSION: u64 = 270; // Request metadata pub mod request_metadata { pub const CODE_VERSION: &str = "code_version"; pub const STATE_VERSION: &str = "state_version"; pub const MAJOR_DESKTOP_VERSION: &str = "major_desktop_version"; pub const DEVICE_TYPE: &str = "device_type"; pub const USER_ID: &str = "user_id"; pub const DEVICE_ID: &str = "device_id"; pub const ACCESS_TOKEN: &str = "access_token"; } // CORS pub mod cors { use std::time::Duration; pub const DEFAULT_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60); pub const DEFAULT_EXPOSED_HEADERS: [&str; 3] = ["grpc-status", "grpc-message", "grpc-status-details-bin"]; pub const DEFAULT_ALLOW_HEADERS: [&str; 11] = [ "x-grpc-web", "content-type", "x-user-agent", "grpc-timeout", super::request_metadata::CODE_VERSION, super::request_metadata::STATE_VERSION, super::request_metadata::MAJOR_DESKTOP_VERSION, super::request_metadata::DEVICE_TYPE, super::request_metadata::USER_ID, super::request_metadata::DEVICE_ID, super::request_metadata::ACCESS_TOKEN, ]; pub const ALLOW_ORIGIN_LIST: &str = "ALLOW_ORIGIN_LIST"; pub const PROD_ORIGIN_HOST_STR: &str = "web.comm.app"; } // Tracing pub const COMM_SERVICES_USE_JSON_LOGS: &str = "COMM_SERVICES_USE_JSON_LOGS"; // Regex pub const VALID_USERNAME_REGEX_STRING: &str = r"^[a-zA-Z0-9][a-zA-Z0-9-_]{0,190}$"; // Retry // TODO: Replace this with `ExponentialBackoffConfig` from `comm-lib` pub mod retry { pub const MAX_ATTEMPTS: usize = 8; pub const CONDITIONAL_CHECK_FAILED: &str = "ConditionalCheckFailed"; pub const TRANSACTION_CONFLICT: &str = "TransactionConflict"; } // One-time keys pub const ONE_TIME_KEY_UPLOAD_LIMIT_PER_ACCOUNT: usize = 24; pub const ONE_TIME_KEY_SIZE: usize = 43; // as defined in olm pub const MAX_ONE_TIME_KEYS: usize = 100; // as defined in olm diff --git a/services/identity/src/grpc_utils.rs b/services/identity/src/grpc_utils.rs index a76a38aa5..d6f41949d 100644 --- a/services/identity/src/grpc_utils.rs +++ b/services/identity/src/grpc_utils.rs @@ -1,361 +1,380 @@ use base64::{engine::general_purpose, Engine as _}; use ed25519_dalek::{PublicKey, Signature, Verifier}; use serde::Deserialize; use tonic::Status; use tracing::warn; use crate::{ + constants::tonic_status_messages, database::{DeviceListUpdate, DeviceRow, KeyPayload}, ddb_utils::{DBIdentity, Identifier as DBIdentifier}, device_list::SignedDeviceList, grpc_services::protos::{ auth::{EthereumIdentity, Identity, InboundKeyInfo, OutboundKeyInfo}, unauth::{ DeviceKeyUpload, ExistingDeviceLoginRequest, OpaqueLoginStartRequest, RegistrationStartRequest, ReservedRegistrationStartRequest, ReservedWalletRegistrationRequest, SecondaryDeviceKeysUploadRequest, WalletAuthRequest, }, }, }; #[derive(Deserialize)] pub struct SignedNonce { nonce: String, signature: String, } impl TryFrom<&SecondaryDeviceKeysUploadRequest> for SignedNonce { type Error = Status; fn try_from( value: &SecondaryDeviceKeysUploadRequest, ) -> Result { Ok(Self { nonce: value.nonce.to_string(), signature: value.nonce_signature.to_string(), }) } } impl TryFrom<&ExistingDeviceLoginRequest> for SignedNonce { type Error = Status; fn try_from(value: &ExistingDeviceLoginRequest) -> Result { Ok(Self { nonce: value.nonce.to_string(), signature: value.nonce_signature.to_string(), }) } } impl SignedNonce { pub fn verify_and_get_nonce( self, signing_public_key: &str, ) -> Result { ed25519_verify(signing_public_key, &self.nonce, &self.signature)?; Ok(self.nonce) } } /// Verifies ed25519-signed message. Returns Ok if the signature is valid. /// Public key and signature should be base64-encoded strings. pub fn ed25519_verify( signing_public_key: &str, message: &str, signature: &str, ) -> Result<(), Status> { let signature_bytes = general_purpose::STANDARD_NO_PAD .decode(signature) .map_err(|_| Status::invalid_argument("signature invalid"))?; let signature = Signature::from_bytes(&signature_bytes) .map_err(|_| Status::invalid_argument("signature invalid"))?; let public_key_bytes = general_purpose::STANDARD_NO_PAD .decode(signing_public_key) .map_err(|_| Status::failed_precondition("malformed key"))?; let public_key: PublicKey = PublicKey::from_bytes(&public_key_bytes) .map_err(|_| Status::failed_precondition("malformed key"))?; public_key .verify(message.as_bytes(), &signature) .map_err(|_| Status::permission_denied("verification failed"))?; Ok(()) } pub struct DeviceKeysInfo { pub device_info: DeviceRow, pub content_one_time_key: Option, pub notif_one_time_key: Option, } impl From for DeviceKeysInfo { fn from(device_info: DeviceRow) -> Self { Self { device_info, content_one_time_key: None, notif_one_time_key: None, } } } impl From for InboundKeyInfo { fn from(info: DeviceKeysInfo) -> Self { let DeviceKeysInfo { device_info, .. } = info; InboundKeyInfo { identity_info: Some(device_info.device_key_info.into()), content_prekey: Some(device_info.content_prekey.into()), notif_prekey: Some(device_info.notif_prekey.into()), } } } impl From for OutboundKeyInfo { fn from(info: DeviceKeysInfo) -> Self { let DeviceKeysInfo { device_info, content_one_time_key, notif_one_time_key, } = info; OutboundKeyInfo { identity_info: Some(device_info.device_key_info.into()), content_prekey: Some(device_info.content_prekey.into()), notif_prekey: Some(device_info.notif_prekey.into()), one_time_content_prekey: content_one_time_key, one_time_notif_prekey: notif_one_time_key, } } } pub trait DeviceKeyUploadData { fn device_key_upload(&self) -> Option<&DeviceKeyUpload>; } impl DeviceKeyUploadData for RegistrationStartRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for ReservedRegistrationStartRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for OpaqueLoginStartRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for WalletAuthRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for ReservedWalletRegistrationRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } impl DeviceKeyUploadData for SecondaryDeviceKeysUploadRequest { fn device_key_upload(&self) -> Option<&DeviceKeyUpload> { self.device_key_upload.as_ref() } } pub trait DeviceKeyUploadActions { fn payload(&self) -> Result; fn payload_signature(&self) -> Result; fn content_prekey(&self) -> Result; fn content_prekey_signature(&self) -> Result; fn notif_prekey(&self) -> Result; fn notif_prekey_signature(&self) -> Result; fn one_time_content_prekeys(&self) -> Result, Status>; fn one_time_notif_prekeys(&self) -> Result, Status>; fn device_type(&self) -> Result; } impl DeviceKeyUploadActions for T { fn payload(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.device_key_info.as_ref()) .map(|info| info.payload.clone()) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } fn payload_signature(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.device_key_info.as_ref()) .map(|info| info.payload_signature.clone()) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } fn content_prekey(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.content_upload.as_ref()) .map(|prekey| prekey.prekey.clone()) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } fn content_prekey_signature(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.content_upload.as_ref()) .map(|prekey| prekey.prekey_signature.clone()) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } fn notif_prekey(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.notif_upload.as_ref()) .map(|prekey| prekey.prekey.clone()) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } fn notif_prekey_signature(&self) -> Result { self .device_key_upload() .and_then(|upload| upload.notif_upload.as_ref()) .map(|prekey| prekey.prekey_signature.clone()) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } fn one_time_content_prekeys(&self) -> Result, Status> { self .device_key_upload() .map(|upload| upload.one_time_content_prekeys.clone()) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } fn one_time_notif_prekeys(&self) -> Result, Status> { self .device_key_upload() .map(|upload| upload.one_time_notif_prekeys.clone()) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } fn device_type(&self) -> Result { self .device_key_upload() .map(|upload| upload.device_type) - .ok_or_else(|| Status::invalid_argument("unexpected message data")) + .ok_or_else(|| { + Status::invalid_argument(tonic_status_messages::UNEXPECTED_MESSAGE_DATA) + }) } } /// Common functionality for registration request messages trait RegistrationData { fn initial_device_list(&self) -> &str; } impl RegistrationData for RegistrationStartRequest { fn initial_device_list(&self) -> &str { &self.initial_device_list } } impl RegistrationData for ReservedRegistrationStartRequest { fn initial_device_list(&self) -> &str { &self.initial_device_list } } impl RegistrationData for WalletAuthRequest { fn initial_device_list(&self) -> &str { &self.initial_device_list } } impl RegistrationData for ReservedWalletRegistrationRequest { fn initial_device_list(&self) -> &str { &self.initial_device_list } } /// Similar to `[DeviceKeyUploadActions]` but only for registration requests pub trait RegistrationActions { fn get_and_verify_initial_device_list( &self, ) -> Result, tonic::Status>; } impl RegistrationActions for T { fn get_and_verify_initial_device_list( &self, ) -> Result, tonic::Status> { let payload = self.initial_device_list(); if payload.is_empty() { return Ok(None); } let signed_list: SignedDeviceList = payload.parse().map_err(|err| { warn!("Failed to deserialize initial device list: {}", err); tonic::Status::invalid_argument("invalid device list payload") })?; let key_info = self .payload()? .parse::() .map_err(|_| tonic::Status::invalid_argument("malformed payload"))?; let primary_device_id = key_info.primary_identity_public_keys.ed25519; let update_payload = DeviceListUpdate::try_from(signed_list.clone())?; crate::device_list::verify_initial_device_list( &update_payload, &primary_device_id, )?; Ok(Some(signed_list)) } } impl From for Identity { fn from(value: DBIdentity) -> Self { match value.identifier { DBIdentifier::Username(username) => Identity { username, eth_identity: None, farcaster_id: value.farcaster_id, }, DBIdentifier::WalletAddress(eth_identity) => Identity { username: eth_identity.wallet_address.clone(), eth_identity: Some(EthereumIdentity { wallet_address: eth_identity.wallet_address, siwe_message: eth_identity.social_proof.message, siwe_signature: eth_identity.social_proof.signature, }), farcaster_id: value.farcaster_id, }, } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_challenge_response_verification() { let expected_nonce = "hello"; let signing_key = "jnBariweGMSdfmJYvuObTu4IGT1fpaJTo/ovbkU0SAY"; let request = SecondaryDeviceKeysUploadRequest { nonce: expected_nonce.to_string(), nonce_signature: "LWlgCDND3bmgIS8liW/0eKJvuNs4Vcb4iMf43zD038/MnC0cSAYl2l3bO9dFc0fa2w6/2ABsUlPDMVr+isE0Aw".to_string(), user_id: "foo".to_string(), device_key_upload: None, }; let challenge_response = SignedNonce::try_from(&request) .expect("failed to parse challenge response"); let retrieved_nonce = challenge_response .verify_and_get_nonce(signing_key) .expect("verification failed"); assert_eq!(retrieved_nonce, expected_nonce); } }