From 65720cdb900d343c049251fb5650c245bd97e1ca Mon Sep 17 00:00:00 2001 From: cp-megh Date: Tue, 7 Jan 2025 16:08:19 +0530 Subject: [PATCH 1/7] WIP --- .../canopas/yourspace/YourSpaceApplication.kt | 5 + .../domain/keyrotation/KeyRotationWorker.kt | 68 +++++ .../data/models/location/LocationJourney.kt | 12 +- .../yourspace/data/models/space/ApiSpace.kt | 1 + .../service/location/ApiJourneyService.kt | 274 ++++++++++++------ .../service/location/ApiLocationService.kt | 15 +- .../data/service/space/ApiSpaceService.kt | 58 +++- .../BufferedSenderKeyStore.kt | 2 +- .../data/utils/EphemeralECDHUtils.kt | 2 +- 9 files changed, 325 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/canopas/yourspace/domain/keyrotation/KeyRotationWorker.kt diff --git a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt index 280911f1..0da90bc7 100644 --- a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt +++ b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt @@ -19,6 +19,7 @@ import com.canopas.yourspace.domain.fcm.FcmRegisterWorker import com.canopas.yourspace.domain.fcm.YOURSPACE_CHANNEL_GEOFENCE import com.canopas.yourspace.domain.fcm.YOURSPACE_CHANNEL_MESSAGES import com.canopas.yourspace.domain.fcm.YOURSPACE_CHANNEL_PLACES +import com.canopas.yourspace.domain.keyrotation.KeyRotationWorker import com.canopas.yourspace.domain.receiver.BatteryBroadcastReceiver import com.google.android.libraries.places.api.Places import com.google.firebase.crashlytics.FirebaseCrashlytics @@ -122,6 +123,10 @@ class YourSpaceApplication : if (userPreferences.currentUser != null && !userPreferences.isFCMRegistered) { FcmRegisterWorker.startService(this) } + + if (userPreferences.currentUser != null && userPreferences.currentUser?.identity_key_public != null) { + KeyRotationWorker.schedule(this) + } } override val workManagerConfiguration: Configuration diff --git a/app/src/main/java/com/canopas/yourspace/domain/keyrotation/KeyRotationWorker.kt b/app/src/main/java/com/canopas/yourspace/domain/keyrotation/KeyRotationWorker.kt new file mode 100644 index 00000000..702bfb6c --- /dev/null +++ b/app/src/main/java/com/canopas/yourspace/domain/keyrotation/KeyRotationWorker.kt @@ -0,0 +1,68 @@ +package com.canopas.yourspace.domain.keyrotation + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.canopas.yourspace.data.service.space.ApiSpaceService +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.util.concurrent.TimeUnit + +private const val KEY_ROTATION_WORKER = "KeyRotationWorker" + +@HiltWorker +class KeyRotationWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted workerParams: WorkerParameters, + private val apiSpaceService: ApiSpaceService +) : CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + Timber.d("Starting Key Rotation Worker...") + + return@withContext try { + val spaceIds = apiSpaceService.getUserSpacesToRotateKeys() + spaceIds.forEach { spaceId -> + apiSpaceService.rotateSenderKey(spaceId) + Timber.d("Rotated keys for space: $spaceId") + } + Timber.d("Key Rotation Worker completed successfully.") + Result.success() + } catch (e: Exception) { + Timber.e(e, "Error in Key Rotation Worker") + Result.retry() + } + } + + companion object { + fun schedule(context: Context) { + val workRequest = PeriodicWorkRequestBuilder(7, TimeUnit.DAYS) + .setInitialDelay(7, TimeUnit.DAYS) + .setConstraints( + Constraints.Builder() + .setRequiresBatteryNotLow(true) + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + ) + .build() + + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + KEY_ROTATION_WORKER, + ExistingPeriodicWorkPolicy.KEEP, + workRequest + ) + + Timber.d("Scheduled Key Rotation Worker") + } + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt b/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt index 6113e514..79328703 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt @@ -22,7 +22,8 @@ data class LocationJourney( val routes: List = emptyList(), val created_at: Long = System.currentTimeMillis(), val updated_at: Long = System.currentTimeMillis(), - val type: JourneyType? = null + val type: JourneyType? = null, + val key_id: String = "" ) @Keep @@ -39,7 +40,8 @@ data class EncryptedLocationJourney( val routes: List = emptyList(), // Encrypted journey routes val created_at: Long = System.currentTimeMillis(), val updated_at: Long = System.currentTimeMillis(), - val type: JourneyType? = null + val type: JourneyType? = null, + val key_id: String = "" ) @Keep @@ -140,7 +142,8 @@ fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher route_duration = route_duration, routes = decryptedRoutes, created_at = created_at, - updated_at = updated_at + updated_at = updated_at, + key_id = key_id ) } @@ -200,6 +203,7 @@ fun LocationJourney.toEncryptedLocationJourney( route_duration = route_duration, routes = encryptedRoutes, created_at = created_at, - updated_at = updated_at + updated_at = updated_at, + key_id = distributionId.toString() ) } diff --git a/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt b/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt index be2ee9c1..a16964de 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt @@ -74,6 +74,7 @@ data class MemberKeyData( * encrypted with the recipient's public key. */ data class EncryptedDistribution( + val id: String = UUID.randomUUID().toString(), val recipientId: String = "", val ephemeralPub: Blob = Blob.fromBytes(ByteArray(0)), // 32 bytes val iv: Blob = Blob.fromBytes(ByteArray(0)), // 12 bytes diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt index 7f22f666..5fa91d4b 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt @@ -55,49 +55,70 @@ class ApiJourneyService @Inject constructor( .collection(FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) .document(FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) - private suspend fun getGroupCipherAndDistributionMessage( + private suspend fun getGroupKeyDoc(spaceId: String): GroupKeysDoc? { + return try { + spaceGroupKeysRef(spaceId).get().await().toObject() + } catch (e: Exception) { + Timber.e(e, "Failed to fetch GroupKeysDoc for space: $spaceId") + null + } + } + + /** + * Loads the group cipher for the given [spaceId], [userId], [keyId], and already-loaded [groupKeysDoc]. + */ + private suspend fun getGroupCipherByKeyId( spaceId: String, - userId: String + userId: String, + keyId: String? = null, + groupKeysDoc: GroupKeysDoc ): Pair? { - val snapshot = spaceGroupKeysRef(spaceId).get().await() - val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: return null - val memberKeyData = groupKeysDoc.memberKeys[userId] ?: return null + Timber.e("XXXXXX: SpaceId: $spaceId, userId: $userId, keyId: $keyId") + val memberKeysData = groupKeysDoc.memberKeys[userId] ?: return null + val distribution = memberKeysData.distributions + .firstOrNull { + it.recipientId == userId && (keyId == null || it.id == keyId) + } ?: return null - val currentUser = userPreferences.currentUser ?: return null - val privateKey = getCurrentUserPrivateKey(currentUser) ?: return null + Timber.e("XXXXXX: distribution: $distribution") + val privateKey = getCurrentUserPrivateKey(userPreferences.currentUser!!) ?: return null - val distribution = - memberKeyData.distributions.firstOrNull { it.recipientId == currentUser.id } - ?: return null - val decryptedDistributionBytes = - EphemeralECDHUtils.decrypt(distribution, privateKey) ?: return null - val distributionMessage = SenderKeyDistributionMessage(decryptedDistributionBytes) + // Decrypt the distribution message + val decryptedBytes = EphemeralECDHUtils.decrypt(distribution, privateKey) ?: return null + val distributionMessage = SenderKeyDistributionMessage(decryptedBytes) - val groupAddress = SignalProtocolAddress(spaceId, memberKeyData.memberDeviceId) + Timber.e("XXXXXX: distributionMessage: $distributionMessage") + val groupAddress = SignalProtocolAddress(spaceId, memberKeysData.memberDeviceId) + // Ensures the distribution ID is loaded into the store bufferedSenderKeyStore.loadSenderKey(groupAddress, distributionMessage.distributionId) - // Initialize the session - try { + return try { GroupSessionBuilder(bufferedSenderKeyStore).process(groupAddress, distributionMessage) - val groupCipher = GroupCipher(bufferedSenderKeyStore, groupAddress) - return Pair(distributionMessage, groupCipher) + distributionMessage to GroupCipher(bufferedSenderKeyStore, groupAddress) } catch (e: Exception) { - Timber.e(e, "Error processing group session") - return null + Timber.e(e, "Error processing group session for space: $spaceId") + null } } - private suspend fun withGroupCipher( + /** + * Helper to run some block of code that needs a [GroupCipher], using an already-loaded [GroupKeysDoc]. + * Returns `defaultValue` if we fail to load the cipher or if the block returns null. + */ + private suspend inline fun runWithGroupCipher( + spaceId: String, userId: String, - block: suspend (GroupCipher) -> T?, - defaultValue: T + groupKeysDoc: GroupKeysDoc, + keyId: String? = null, + defaultValue: T, + crossinline block: (cipher: GroupCipher) -> T? ): T { - val (_, groupCipher) = getGroupCipherAndDistributionMessage(currentSpaceId, userId) + val (_, groupCipher) = getGroupCipherByKeyId(spaceId, userId, keyId, groupKeysDoc) ?: return defaultValue return try { block(groupCipher) ?: defaultValue } catch (e: Exception) { - Timber.e(e, "Error executing operation for userId: $userId") + Timber.e(e, "Error executing operation for userId: $userId in spaceId: $spaceId") defaultValue } } @@ -106,7 +127,8 @@ class ApiJourneyService @Inject constructor( * Decrypts and retrieves the current user's private key. */ private suspend fun getCurrentUserPrivateKey(currentUser: ApiUser): ECPrivateKey? { - val privateKey = userPreferences.getPrivateKey() ?: currentUser.identity_key_private?.toBytes() + val privateKey = + userPreferences.getPrivateKey() ?: currentUser.identity_key_private?.toBytes() return try { Curve.decodePrivatePoint(privateKey) } catch (e: InvalidKeyException) { @@ -119,6 +141,9 @@ class ApiJourneyService @Inject constructor( } } + /** + * Saves a new [LocationJourney] in all of the current user's spaces. + */ suspend fun saveCurrentJourney( userId: String, fromLatitude: Double, @@ -133,12 +158,23 @@ class ApiJourneyService @Inject constructor( type: JourneyType? = null, newJourneyId: ((String) -> Unit)? = null ) { - userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val cipherAndMessage = getGroupCipherAndDistributionMessage(spaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $spaceId, userId: $userId") - return@forEach - } - val (distributionMessage, groupCipher) = cipherAndMessage + val currentUser = userPreferences.currentUser ?: return + val spaceIds = currentUser.space_ids.orEmpty() + + spaceIds.forEach { spaceId -> + // Load groupKeysDoc once (per space) and reuse it if needed + val groupKeysDoc = getGroupKeyDoc(spaceId) ?: return@forEach + + val (distributionMessage, groupCipher) = getGroupCipherByKeyId( + spaceId, + userId, + null, + groupKeysDoc + ) + ?: run { + Timber.e("Failed to get group cipher for spaceId=$spaceId, userId=$userId") + return@forEach + } val journey = LocationJourney( id = UUID.randomUUID().toString(), @@ -155,58 +191,90 @@ class ApiJourneyService @Inject constructor( type = type ) - val docRef = spaceMemberJourneyRef(spaceId, userId).document(journey.id) - val encryptedJourney = journey.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) newJourneyId?.invoke(encryptedJourney.id) - docRef.set(encryptedJourney).await() + try { + spaceMemberJourneyRef(spaceId, userId) + .document(journey.id) + .set(encryptedJourney) + .await() + } catch (e: Exception) { + Timber.e(e, "Error saving journey for spaceId: $spaceId, userId: $userId") + } } } + /** + * Updates the last [LocationJourney] for [userId]. + */ suspend fun updateLastLocationJourney(userId: String, journey: LocationJourney) { - userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val cipherAndMessage = getGroupCipherAndDistributionMessage(spaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $spaceId, userId: $userId") - return@forEach - } - val (distributionMessage, groupCipher) = cipherAndMessage + val currentUser = userPreferences.currentUser ?: return + val spaceIds = currentUser.space_ids.orEmpty() + + spaceIds.forEach { spaceId -> + val groupKeysDoc = getGroupKeyDoc(spaceId) ?: return@forEach + + val (distributionMessage, groupCipher) = getGroupCipherByKeyId( + spaceId, + userId, + journey.key_id, + groupKeysDoc + ) + ?: run { + Timber.e("Failed to get group cipher for spaceId=$spaceId, userId=$userId") + return@forEach + } val encryptedJourney = journey.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) try { - spaceMemberJourneyRef(spaceId, userId).document(journey.id).set(encryptedJourney) + spaceMemberJourneyRef(spaceId, userId) + .document(journey.id) + .set(encryptedJourney) .await() } catch (e: Exception) { - Timber.e( - e, - "Error while updating last location journey for spaceId: $spaceId, userId: $userId" - ) + Timber.e(e, "Error updating journey for spaceId=$spaceId, userId=$userId") } } } - suspend fun getLastJourneyLocation(userId: String): LocationJourney? = - withGroupCipher(userId, { groupCipher -> - spaceMemberJourneyRef(currentSpaceId, userId) - .whereEqualTo("user_id", userId) - .orderBy("created_at", Query.Direction.DESCENDING) - .limit(1) - .get() - .await() - .documents - .firstOrNull() - ?.toObject() - ?.toDecryptedLocationJourney(groupCipher) - }, null) + /** + * Fetches the most recent [LocationJourney] for [userId] in the current space. + */ + suspend fun getLastJourneyLocation(userId: String): LocationJourney? { + val encryptedJourney = spaceMemberJourneyRef(currentSpaceId, userId) + .orderBy("created_at", Query.Direction.DESCENDING) + .limit(1) + .get() + .await() + .documents + .firstOrNull() + ?.toObject() + ?: return null - suspend fun getMoreJourneyHistory(userId: String, from: Long?): List { - val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: $userId") - return emptyList() + // Fetch groupKeysDoc once + val groupKeysDoc = getGroupKeyDoc(currentSpaceId) ?: return null + + // Decrypt + return runWithGroupCipher( + spaceId = currentSpaceId, + userId = userId, + groupKeysDoc = groupKeysDoc, + keyId = encryptedJourney.key_id, + defaultValue = null + ) { cipher -> + encryptedJourney.toDecryptedLocationJourney(cipher) } - val (_, groupCipher) = cipherAndMessage + } + + /** + * Fetch more journey history, older than [from], in pages of up to 20. + * Loads GroupKeysDoc once, then decrypts each journey. + */ + suspend fun getMoreJourneyHistory(userId: String, from: Long?): List { + val groupKeysDoc = getGroupKeyDoc(currentSpaceId) ?: return emptyList() val query = if (from == null) { spaceMemberJourneyRef(currentSpaceId, userId) @@ -221,25 +289,27 @@ class ApiJourneyService @Inject constructor( .limit(20) } - return try { - query.get().await().documents.mapNotNull { - it.toObject()?.toDecryptedLocationJourney(groupCipher) - } - } catch (e: Exception) { - Timber.e(e, "Error while getting journey history for userId: $userId") - emptyList() + val encryptedJourneys = query.get().await().documents.mapNotNull { + it.toObject() } - } - suspend fun getJourneyHistory(userId: String, from: Long, to: Long): List { - val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: $userId") - return emptyList() + return encryptedJourneys.mapNotNull { encrypted -> + val cipherAndMessage = + getGroupCipherByKeyId(currentSpaceId, userId, encrypted.key_id, groupKeysDoc) + ?: return@mapNotNull null + encrypted.toDecryptedLocationJourney(cipherAndMessage.second) } - val (_, groupCipher) = cipherAndMessage + } + /** + * Fetch journey history between [from] and [to]. + * Loads GroupKeysDoc once, then decrypts each journey. + */ + suspend fun getJourneyHistory(userId: String, from: Long, to: Long): List { return try { - val previousDayJourney = spaceMemberJourneyRef(currentSpaceId, userId) + val groupKeysDoc = getGroupKeyDoc(currentSpaceId) ?: return emptyList() + + val previousDayEncrypted = spaceMemberJourneyRef(currentSpaceId, userId) .whereEqualTo("user_id", userId) .whereLessThan("created_at", from) .whereGreaterThanOrEqualTo("updated_at", from) @@ -247,11 +317,9 @@ class ApiJourneyService @Inject constructor( .get() .await() .documents - .mapNotNull { - it.toObject()?.toDecryptedLocationJourney(groupCipher) - } + .mapNotNull { it.toObject() } - val currentDayJourney = spaceMemberJourneyRef(currentSpaceId, userId) + val currentDayEncrypted = spaceMemberJourneyRef(currentSpaceId, userId) .whereEqualTo("user_id", userId) .whereGreaterThanOrEqualTo("created_at", from) .whereLessThanOrEqualTo("created_at", to) @@ -260,35 +328,49 @@ class ApiJourneyService @Inject constructor( .get() .await() .documents - .mapNotNull { - it.toObject()?.toDecryptedLocationJourney(groupCipher) - } + .mapNotNull { it.toObject() } - previousDayJourney + currentDayJourney + val allEncrypted = previousDayEncrypted + currentDayEncrypted + + allEncrypted.mapNotNull { encrypted -> + val cipherAndMessage = + getGroupCipherByKeyId(currentSpaceId, userId, encrypted.key_id, groupKeysDoc) + ?: return@mapNotNull null + encrypted.toDecryptedLocationJourney(cipherAndMessage.second) + } } catch (e: Exception) { Timber.e(e, "Error while getting journey history for userId: $userId") emptyList() } } + /** + * Retrieves a specific [LocationJourney] by its [journeyId]. + */ suspend fun getLocationJourneyFromId(journeyId: String): LocationJourney? { val currentUser = userPreferences.currentUser ?: return null - val cipherAndMessage = - getGroupCipherAndDistributionMessage(currentSpaceId, currentUser.id) ?: run { - Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: ${currentUser.id}") - return null - } - val (_, groupCipher) = cipherAndMessage - return try { - spaceMemberJourneyRef(currentSpaceId, currentUser.id).document(journeyId) + val encryptedJourney = try { + spaceMemberJourneyRef(currentSpaceId, currentUser.id) + .document(journeyId) .get() .await() .toObject() - ?.toDecryptedLocationJourney(groupCipher) } catch (e: Exception) { Timber.e(e, "Error while getting journey by ID: $journeyId") - null + return null + } + + val groupKeysDoc = getGroupKeyDoc(currentSpaceId) ?: return null + + return runWithGroupCipher( + spaceId = currentSpaceId, + userId = currentUser.id, + groupKeysDoc = groupKeysDoc, + keyId = encryptedJourney?.key_id, + defaultValue = null + ) { cipher -> + encryptedJourney?.toDecryptedLocationJourney(cipher) } } } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt index 4632db19..185bb114 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt @@ -181,10 +181,7 @@ class ApiLocationService @Inject constructor( val latitude = latitudeBytes.toString(Charsets.UTF_8).toDoubleOrNull() val longitude = longitudeBytes.toString(Charsets.UTF_8).toDoubleOrNull() - if (latitude == null || longitude == null) { - Timber.e("Failed to decrypt location for userId: $userId") - return null - } + if (latitude == null || longitude == null) return null ApiLocation( id = encryptedLocation.id, @@ -225,7 +222,7 @@ class ApiLocationService @Inject constructor( val decryptedDistribution = EphemeralECDHUtils.decrypt(distribution, currentUserPrivateKey) ?: run { - Timber.e("Failed to decrypt distribution for userId=$userId in spaceId=$spaceId") + Timber.e("Failed to decrypt distribution for userId=$userId") return null } @@ -258,7 +255,7 @@ class ApiLocationService @Inject constructor( val groupCipher = GroupCipher(bufferedSenderKeyStore, groupAddress) Pair(distributionMessage, groupCipher) } catch (e: Exception) { - Timber.e(e, "Error processing group session for spaceId=$spaceId, userId=$userId") + Timber.e(e, "Error processing group session for userId=$userId") null } } @@ -278,9 +275,9 @@ class ApiLocationService @Inject constructor( } catch (e: InvalidKeyException) { Timber.e(e, "Error decoding private key for userId=${currentUser.id}") PrivateKeyUtils.decryptPrivateKey( - privateKey ?: return null, - currentUser.identity_key_salt?.toBytes() ?: return null, - userPreferences.getPasskey() ?: return null + encryptedPrivateKey = privateKey ?: return null, + salt = currentUser.identity_key_salt?.toBytes() ?: return null, + passkey = userPreferences.getPasskey() ?: return null )?.let { Curve.decodePrivatePoint(it) } } } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt index 1323f63e..9fa15360 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt @@ -100,7 +100,7 @@ class ApiSpaceService @Inject constructor( val deviceId = UUID.randomUUID().mostSignificantBits and 0x7FFFFFFF val groupAddress = SignalProtocolAddress(spaceId, deviceId.toInt()) val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) - val distributionMessage = sessionBuilder.create(groupAddress, UUID.fromString(spaceId)) + val distributionMessage = sessionBuilder.create(groupAddress, UUID.randomUUID()) val distributionBytes = distributionMessage.serialize() val apiSpaceMembers = getSpaceMembers(spaceId) @@ -215,4 +215,60 @@ class ApiSpaceService @Inject constructor( } } } + + suspend fun rotateSenderKey(spaceId: String) { + val user = authService.currentUser ?: return + val deviceId = UUID.randomUUID().mostSignificantBits.toInt() + + val groupAddress = SignalProtocolAddress(spaceId, deviceId) + val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) + val newDistributionMessage = sessionBuilder.create(groupAddress, UUID.randomUUID()) + val newDistributionBytes = newDistributionMessage.serialize() + + val apiSpaceMembers = getSpaceMembers(spaceId) + val memberIds = apiSpaceMembers.map { it.user_id }.toSet() + val newDistributions = mutableListOf() + + for (member in apiSpaceMembers) { + val publicBlob = member.identity_key_public ?: continue + val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) + + newDistributions.add(EphemeralECDHUtils.encrypt(member.user_id, newDistributionBytes, publicKey)) + } + + db.runTransaction { transaction -> + val docRef = spaceGroupKeysDoc(spaceId) + val snapshot = transaction.get(docRef) + val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() + + val oldKeyData = groupKeysDoc.memberKeys[user.id] ?: MemberKeyData() + + // Filter out distributions for members who are no longer in the space + val filteredOldDistributions = oldKeyData.distributions.filter { it.recipientId in memberIds } + + val rotatedKeyData = oldKeyData.copy( + memberDeviceId = deviceId, + distributions = newDistributions + filteredOldDistributions, + dataUpdatedAt = System.currentTimeMillis() + ) + + val updates = mapOf( + "memberKeys.${user.id}" to rotatedKeyData, + "docUpdatedAt" to System.currentTimeMillis() + ) + + transaction.update(docRef, updates) + }.await() + + Timber.d("Key rotation completed for space: $spaceId") + } + + fun getUserSpacesToRotateKeys(): List { + val user = authService.currentUser ?: return emptyList() + if (user.identity_key_public == null) { + Timber.e("User identity key public is null, can't rotate keys") + return emptyList() + } + return user.space_ids ?: emptyList() + } } diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt index a6486676..947aed2c 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt @@ -51,7 +51,7 @@ class BufferedSenderKeyStore @Inject constructor( try { val currentUser = userPreferences.currentUser ?: return val uniqueDocId = "${senderKeyRecord.deviceId}-${senderKeyRecord.distributionId}" - spaceSenderKeyRecordRef(senderKeyRecord.distributionId, currentUser.id) + spaceSenderKeyRecordRef(senderKeyRecord.address, currentUser.id) .document(uniqueDocId).set(senderKeyRecord).await() } catch (e: Exception) { Timber.e(e, "Failed to save sender key to server: $senderKeyRecord") diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt b/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt index 84a149ec..e86697a1 100644 --- a/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt +++ b/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt @@ -32,7 +32,7 @@ object EphemeralECDHUtils { * @param receiverId The unique identifier of the receiver. * @param plaintext The data to be encrypted as a byte array. * @param receiverPub The receiver's public key. - * @return EncryptedDistribution The encrypted data and associated metadata. + * @return EncryptedDistribution: The encrypted data and associated metadata. */ fun encrypt( receiverId: String, From cd86a5b3d9be70d84455a10fb949c4d75c604644 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Tue, 7 Jan 2025 16:49:45 +0530 Subject: [PATCH 2/7] WIP --- .../canopas/yourspace/YourSpaceApplication.kt | 5 -- .../domain/keyrotation/KeyRotationWorker.kt | 68 ------------------- .../data/service/space/ApiSpaceService.kt | 12 +--- 3 files changed, 2 insertions(+), 83 deletions(-) delete mode 100644 app/src/main/java/com/canopas/yourspace/domain/keyrotation/KeyRotationWorker.kt diff --git a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt index 0da90bc7..280911f1 100644 --- a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt +++ b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt @@ -19,7 +19,6 @@ import com.canopas.yourspace.domain.fcm.FcmRegisterWorker import com.canopas.yourspace.domain.fcm.YOURSPACE_CHANNEL_GEOFENCE import com.canopas.yourspace.domain.fcm.YOURSPACE_CHANNEL_MESSAGES import com.canopas.yourspace.domain.fcm.YOURSPACE_CHANNEL_PLACES -import com.canopas.yourspace.domain.keyrotation.KeyRotationWorker import com.canopas.yourspace.domain.receiver.BatteryBroadcastReceiver import com.google.android.libraries.places.api.Places import com.google.firebase.crashlytics.FirebaseCrashlytics @@ -123,10 +122,6 @@ class YourSpaceApplication : if (userPreferences.currentUser != null && !userPreferences.isFCMRegistered) { FcmRegisterWorker.startService(this) } - - if (userPreferences.currentUser != null && userPreferences.currentUser?.identity_key_public != null) { - KeyRotationWorker.schedule(this) - } } override val workManagerConfiguration: Configuration diff --git a/app/src/main/java/com/canopas/yourspace/domain/keyrotation/KeyRotationWorker.kt b/app/src/main/java/com/canopas/yourspace/domain/keyrotation/KeyRotationWorker.kt deleted file mode 100644 index 702bfb6c..00000000 --- a/app/src/main/java/com/canopas/yourspace/domain/keyrotation/KeyRotationWorker.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.canopas.yourspace.domain.keyrotation - -import android.content.Context -import androidx.hilt.work.HiltWorker -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import com.canopas.yourspace.data.service.space.ApiSpaceService -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.util.concurrent.TimeUnit - -private const val KEY_ROTATION_WORKER = "KeyRotationWorker" - -@HiltWorker -class KeyRotationWorker @AssistedInject constructor( - @Assisted context: Context, - @Assisted workerParams: WorkerParameters, - private val apiSpaceService: ApiSpaceService -) : CoroutineWorker(context, workerParams) { - - override suspend fun doWork(): Result = withContext(Dispatchers.IO) { - Timber.d("Starting Key Rotation Worker...") - - return@withContext try { - val spaceIds = apiSpaceService.getUserSpacesToRotateKeys() - spaceIds.forEach { spaceId -> - apiSpaceService.rotateSenderKey(spaceId) - Timber.d("Rotated keys for space: $spaceId") - } - Timber.d("Key Rotation Worker completed successfully.") - Result.success() - } catch (e: Exception) { - Timber.e(e, "Error in Key Rotation Worker") - Result.retry() - } - } - - companion object { - fun schedule(context: Context) { - val workRequest = PeriodicWorkRequestBuilder(7, TimeUnit.DAYS) - .setInitialDelay(7, TimeUnit.DAYS) - .setConstraints( - Constraints.Builder() - .setRequiresBatteryNotLow(true) - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() - ) - .build() - - WorkManager.getInstance(context) - .enqueueUniquePeriodicWork( - KEY_ROTATION_WORKER, - ExistingPeriodicWorkPolicy.KEEP, - workRequest - ) - - Timber.d("Scheduled Key Rotation Worker") - } - } -} diff --git a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt index 9fa15360..3281ea9b 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt @@ -187,6 +187,7 @@ class ApiSpaceService @Inject constructor( .whereEqualTo("user_id", userId).get().await().documents.forEach { it.reference.delete().await() } + rotateSenderKey(spaceId) } suspend fun updateSpace(space: ApiSpace) { @@ -216,7 +217,7 @@ class ApiSpaceService @Inject constructor( } } - suspend fun rotateSenderKey(spaceId: String) { + private suspend fun rotateSenderKey(spaceId: String) { val user = authService.currentUser ?: return val deviceId = UUID.randomUUID().mostSignificantBits.toInt() @@ -262,13 +263,4 @@ class ApiSpaceService @Inject constructor( Timber.d("Key rotation completed for space: $spaceId") } - - fun getUserSpacesToRotateKeys(): List { - val user = authService.currentUser ?: return emptyList() - if (user.identity_key_public == null) { - Timber.e("User identity key public is null, can't rotate keys") - return emptyList() - } - return user.space_ids ?: emptyList() - } } From a9102a9ec0a8b383a6d42ea603024ba76bdd3b42 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Wed, 8 Jan 2025 16:17:55 +0530 Subject: [PATCH 3/7] final commit --- app/build.gradle.kts | 5 +- .../data/models/location/LocationJourney.kt | 22 ++-- .../yourspace/data/models/space/ApiSpace.kt | 5 +- .../service/location/ApiJourneyService.kt | 30 +++-- .../service/location/ApiLocationService.kt | 104 +++++++++--------- .../data/service/space/ApiSpaceService.kt | 52 +-------- firestore.rules | 4 +- 7 files changed, 83 insertions(+), 139 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 76bdd23c..586b7113 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ + import org.jetbrains.kotlin.konan.properties.hasProperty import java.util.Properties @@ -14,6 +15,7 @@ plugins { var versionMajor = 1 var versionMinor = 0 var versionBuild = 0 +val targetSdkVersion: Int = 34 android { namespace = "com.canopas.yourspace" @@ -30,6 +32,8 @@ android { defaultConfig { applicationId = "com.canopas.yourspace" minSdk = 24 + targetSdk = targetSdkVersion + versionCode = versionMajor * 1000000 + versionMinor * 10000 + versionBuild versionName = "$versionMajor.$versionMinor.$versionBuild" setProperty("archivesBaseName", "GroupTrack-$versionName-$versionCode") @@ -59,7 +63,6 @@ android { buildConfigField("String", "PLACE_API_KEY", "\"${p.getProperty("PLACE_API_KEY")}\"") } } - signingConfigs { if (System.getenv("APKSIGN_KEYSTORE") != null) { create("release") { diff --git a/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt b/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt index 0ec95815..6af4ac93 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt @@ -22,7 +22,7 @@ data class LocationJourney( val routes: List = emptyList(), val created_at: Long = System.currentTimeMillis(), val updated_at: Long = System.currentTimeMillis(), - val type: JourneyType? = null, + val type: JourneyType = if (to_latitude != null && to_longitude != null) JourneyType.MOVING else JourneyType.STEADY, val key_id: String = "" ) @@ -40,7 +40,7 @@ data class EncryptedLocationJourney( val routes: List = emptyList(), // Encrypted journey routes val created_at: Long = System.currentTimeMillis(), val updated_at: Long = System.currentTimeMillis(), - val type: JourneyType? = null, + val type: JourneyType = if (to_latitude != null && to_longitude != null) JourneyType.MOVING else JourneyType.STEADY, val key_id: String = "" ) @@ -85,19 +85,9 @@ fun LocationJourney.toRoute(): List { } } -fun LocationJourney.isSteady(): Boolean { - if (type != null) { - return type == JourneyType.STEADY - } - return to_latitude == null || to_longitude == null -} +fun LocationJourney.isSteady() = type == JourneyType.STEADY -fun LocationJourney.isMoving(): Boolean { - if (type != null) { - return type == JourneyType.MOVING - } - return to_latitude != null && to_longitude != null -} +fun LocationJourney.isMoving() = type == JourneyType.MOVING fun LocationJourney.toLocationFromSteadyJourney() = Location("").apply { latitude = this@toLocationFromSteadyJourney.from_latitude @@ -151,6 +141,7 @@ fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher routes = decryptedRoutes, created_at = created_at, updated_at = updated_at, + type = type, key_id = key_id ) } @@ -212,6 +203,7 @@ fun LocationJourney.toEncryptedLocationJourney( routes = encryptedRoutes, created_at = created_at, updated_at = updated_at, - key_id = distributionId.toString() + type = type, + key_id = key_id ) } diff --git a/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt b/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt index a16964de..c584b134 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt @@ -57,7 +57,7 @@ data class GroupKeysDoc( val memberKeys: Map = emptyMap() ) -/* +/** * Data class that represents the entire "groupKeys/{senderUserId}" doc * in Firestore for a single sender's key distribution. */ @@ -78,7 +78,8 @@ data class EncryptedDistribution( val recipientId: String = "", val ephemeralPub: Blob = Blob.fromBytes(ByteArray(0)), // 32 bytes val iv: Blob = Blob.fromBytes(ByteArray(0)), // 12 bytes - val ciphertext: Blob = Blob.fromBytes(ByteArray(0)) // AES/GCM ciphertext + val ciphertext: Blob = Blob.fromBytes(ByteArray(0)), // AES/GCM ciphertext + val createdAt: Long = System.currentTimeMillis() ) { init { validateFieldSizes() diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt index 43018fae..ba4be5b5 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt @@ -56,7 +56,7 @@ class ApiJourneyService @Inject constructor( return try { spaceGroupKeysRef(spaceId).get().await().toObject() } catch (e: Exception) { - Timber.e(e, "Failed to fetch GroupKeysDoc for space: $spaceId") + Timber.e(e, "Failed to fetch GroupKeysDoc") null } } @@ -69,31 +69,29 @@ class ApiJourneyService @Inject constructor( userId: String, keyId: String? = null, groupKeysDoc: GroupKeysDoc - ): Pair? { - Timber.e("XXXXXX: SpaceId: $spaceId, userId: $userId, keyId: $keyId") + ): Triple? { val memberKeysData = groupKeysDoc.memberKeys[userId] ?: return null val distribution = memberKeysData.distributions + .sortedByDescending { it.createdAt } .firstOrNull { it.recipientId == userId && (keyId == null || it.id == keyId) } ?: return null - Timber.e("XXXXXX: distribution: $distribution") val privateKey = getCurrentUserPrivateKey(userPreferences.currentUser!!) ?: return null // Decrypt the distribution message val decryptedBytes = EphemeralECDHUtils.decrypt(distribution, privateKey) ?: return null val distributionMessage = SenderKeyDistributionMessage(decryptedBytes) - Timber.e("XXXXXX: distributionMessage: $distributionMessage") val groupAddress = SignalProtocolAddress(spaceId, memberKeysData.memberDeviceId) // Ensures the distribution ID is loaded into the store bufferedSenderKeyStore.loadSenderKey(groupAddress, distributionMessage.distributionId) return try { GroupSessionBuilder(bufferedSenderKeyStore).process(groupAddress, distributionMessage) - distributionMessage to GroupCipher(bufferedSenderKeyStore, groupAddress) + Triple(distributionMessage, GroupCipher(bufferedSenderKeyStore, groupAddress), distribution.id) } catch (e: Exception) { - Timber.e(e, "Error processing group session for space: $spaceId") + Timber.e(e, "Error processing group session") null } } @@ -115,7 +113,7 @@ class ApiJourneyService @Inject constructor( return try { block(groupCipher) ?: defaultValue } catch (e: Exception) { - Timber.e(e, "Error executing operation for userId: $userId in spaceId: $spaceId") + Timber.e(e, "Error executing run operation") defaultValue } } @@ -147,23 +145,22 @@ class ApiJourneyService @Inject constructor( ): LocationJourney { var journey: LocationJourney = newJourney userPreferences.currentUser?.space_ids?.forEach { spaceId -> - // Load groupKeysDoc once (per space) and reuse it if needed val groupKeysDoc = getGroupKeyDoc(spaceId) ?: return@forEach - val (distributionMessage, groupCipher) = getGroupCipherByKeyId( + val (distributionMessage, groupCipher, keyId) = getGroupCipherByKeyId( spaceId, userId, null, groupKeysDoc ) ?: run { - Timber.e("Failed to get group cipher for spaceId=$spaceId, userId=$userId") + Timber.e("Failed to get group cipher") return@forEach } val docRef = spaceMemberJourneyRef(spaceId, userId).document(newJourney.id) - journey = newJourney.copy(id = docRef.id) + journey = newJourney.copy(id = docRef.id, key_id = keyId) val encryptedJourney = journey.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) @@ -187,7 +184,7 @@ class ApiJourneyService @Inject constructor( groupKeysDoc ) ?: run { - Timber.e("Failed to get group cipher for spaceId=$spaceId, userId=$userId") + Timber.e("Failed to get group cipher") return@forEach } @@ -199,7 +196,7 @@ class ApiJourneyService @Inject constructor( .set(encryptedJourney) .await() } catch (e: Exception) { - Timber.e(e, "Error updating journey for spaceId=$spaceId, userId=$userId") + Timber.e(e, "Error updating journey") } } } @@ -218,7 +215,6 @@ class ApiJourneyService @Inject constructor( ?.toObject() ?: return null - // Fetch groupKeysDoc once val groupKeysDoc = getGroupKeyDoc(currentSpaceId) ?: return null // Decrypt @@ -303,7 +299,7 @@ class ApiJourneyService @Inject constructor( encrypted.toDecryptedLocationJourney(cipherAndMessage.second) } } catch (e: Exception) { - Timber.e(e, "Error while getting journey history for userId: $userId") + Timber.e(e, "Error while getting journey history") emptyList() } } @@ -321,7 +317,7 @@ class ApiJourneyService @Inject constructor( .await() .toObject() } catch (e: Exception) { - Timber.e(e, "Error while getting journey by ID: $journeyId") + Timber.e(e, "Error while getting journey") return null } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt index 185bb114..b5c8a4ba 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt @@ -3,6 +3,7 @@ package com.canopas.yourspace.data.service.location import com.canopas.yourspace.data.models.location.ApiLocation import com.canopas.yourspace.data.models.location.EncryptedApiLocation import com.canopas.yourspace.data.models.space.ApiSpaceMember +import com.canopas.yourspace.data.models.space.EncryptedDistribution import com.canopas.yourspace.data.models.space.GroupKeysDoc import com.canopas.yourspace.data.models.space.MemberKeyData import com.canopas.yourspace.data.models.user.ApiUser @@ -31,6 +32,7 @@ import org.signal.libsignal.protocol.groups.GroupSessionBuilder import org.signal.libsignal.protocol.groups.InvalidSenderKeySessionException import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -214,7 +216,9 @@ class ApiLocationService @Inject constructor( val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: return null val memberKeyData = groupKeysDoc.memberKeys[userId] ?: return null - val distribution = memberKeyData.distributions.firstOrNull { + val distribution = memberKeyData.distributions.sortedByDescending { + it.createdAt + }.firstOrNull { it.recipientId == currentUser.id } ?: return null @@ -234,20 +238,7 @@ class ApiLocationService @Inject constructor( // If the sender key data is outdated, we need to distribute the sender key to the pending users if (memberKeyData.dataUpdatedAt < groupKeysDoc.docUpdatedAt && canDistributeSenderKey) { // Here means the sender key data is outdated, so we need to distribute the sender key to the users. - val apiSpaceMembers = getSpaceMembers(spaceId) - val membersPendingForSenderKey = apiSpaceMembers.filter { member -> - memberKeyData.distributions.none { it.recipientId == member.user_id } - } - - if (membersPendingForSenderKey.isNotEmpty()) { - distributeSenderKeyToNewSpaceMembers( - spaceId = spaceId, - senderUserId = userId, - distributionMessage = distributionMessage, - senderDeviceId = memberKeyData.memberDeviceId, - apiSpaceMembers = membersPendingForSenderKey - ) - } + rotateSenderKey(spaceId = spaceId, deviceId = memberKeyData.memberDeviceId) } return try { @@ -267,13 +258,13 @@ class ApiLocationService @Inject constructor( val privateKey = try { userPreferences.getPrivateKey() ?: currentUser.identity_key_private?.toBytes() } catch (e: Exception) { - Timber.e(e, "Failed to retrieve private key for user ${currentUser.id}") + Timber.e(e, "Failed to retrieve private key") return null } return try { Curve.decodePrivatePoint(privateKey) } catch (e: InvalidKeyException) { - Timber.e(e, "Error decoding private key for userId=${currentUser.id}") + Timber.e(e, "Error decoding private key") PrivateKeyUtils.decryptPrivateKey( encryptedPrivateKey = privateKey ?: return null, salt = currentUser.identity_key_salt?.toBytes() ?: return null, @@ -283,49 +274,56 @@ class ApiLocationService @Inject constructor( } /** - * Create a sender key distribution for the new users, and encrypt the distribution key - * for each member using their public key(ECDH). - **/ - private suspend fun distributeSenderKeyToNewSpaceMembers( - spaceId: String, - senderUserId: String, - distributionMessage: SenderKeyDistributionMessage, - senderDeviceId: Int, - apiSpaceMembers: List - ) { + * Rotates the sender key for a given space. + */ + private suspend fun rotateSenderKey(spaceId: String, deviceId: Int) { + val user = userPreferences.currentUser ?: return + + val groupAddress = SignalProtocolAddress(spaceId, deviceId) + val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) + val newDistributionMessage = sessionBuilder.create(groupAddress, UUID.randomUUID()) + val newDistributionBytes = newDistributionMessage.serialize() + + val apiSpaceMembers = getSpaceMembers(spaceId) + val memberIds = apiSpaceMembers.map { it.user_id }.toSet() + val newDistributions = mutableListOf() + + for (member in apiSpaceMembers) { + val publicBlob = member.identity_key_public ?: continue + val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) + + newDistributions.add( + EphemeralECDHUtils.encrypt( + member.user_id, + newDistributionBytes, + publicKey + ) + ) + } + db.runTransaction { transaction -> val docRef = spaceGroupKeysRef(spaceId) - val distributionBytes = distributionMessage.serialize() val snapshot = transaction.get(docRef) val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() - val oldMemberKeyData = groupKeysDoc.memberKeys[senderUserId] ?: MemberKeyData() - val distributions = oldMemberKeyData.distributions.toMutableList() - - for (member in apiSpaceMembers) { - val publicBlob = member.identity_key_public ?: continue - val publicKeyBytes = publicBlob.toBytes() - if (publicKeyBytes.size != 33) { // Expected size for compressed EC public key - Timber.e("Invalid public key size for member ${member.user_id}") - continue - } - val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) - - // Encrypt distribution using member's public key - distributions.add( - EphemeralECDHUtils.encrypt( - member.user_id, - distributionBytes, - publicKey - ) - ) - } - val newMemberKeyData = oldMemberKeyData.copy( - memberDeviceId = senderDeviceId, - distributions = distributions, + val oldKeyData = groupKeysDoc.memberKeys[user.id] ?: MemberKeyData() + + // Filter out distributions for members who are no longer in the space + val filteredOldDistributions = + oldKeyData.distributions.filter { it.recipientId in memberIds } + + val rotatedKeyData = oldKeyData.copy( + memberDeviceId = deviceId, + distributions = newDistributions + filteredOldDistributions, dataUpdatedAt = System.currentTimeMillis() ) - transaction.update(docRef, mapOf("memberKeys.$senderUserId" to newMemberKeyData)) + + val updates = mapOf( + "memberKeys.${user.id}" to rotatedKeyData, + "docUpdatedAt" to System.currentTimeMillis() + ) + + transaction.update(docRef, updates) }.await() } } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt index 3281ea9b..ed96a665 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt @@ -187,7 +187,10 @@ class ApiSpaceService @Inject constructor( .whereEqualTo("user_id", userId).get().await().documents.forEach { it.reference.delete().await() } - rotateSenderKey(spaceId) + + // Update the "docUpdatedAt" so others see membership changed and remove sender key for the removed user + val docRef = spaceGroupKeysDoc(spaceId) + docRef.update("docUpdatedAt", System.currentTimeMillis()).await() } suspend fun updateSpace(space: ApiSpace) { @@ -216,51 +219,4 @@ class ApiSpaceService @Inject constructor( } } } - - private suspend fun rotateSenderKey(spaceId: String) { - val user = authService.currentUser ?: return - val deviceId = UUID.randomUUID().mostSignificantBits.toInt() - - val groupAddress = SignalProtocolAddress(spaceId, deviceId) - val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) - val newDistributionMessage = sessionBuilder.create(groupAddress, UUID.randomUUID()) - val newDistributionBytes = newDistributionMessage.serialize() - - val apiSpaceMembers = getSpaceMembers(spaceId) - val memberIds = apiSpaceMembers.map { it.user_id }.toSet() - val newDistributions = mutableListOf() - - for (member in apiSpaceMembers) { - val publicBlob = member.identity_key_public ?: continue - val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) - - newDistributions.add(EphemeralECDHUtils.encrypt(member.user_id, newDistributionBytes, publicKey)) - } - - db.runTransaction { transaction -> - val docRef = spaceGroupKeysDoc(spaceId) - val snapshot = transaction.get(docRef) - val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() - - val oldKeyData = groupKeysDoc.memberKeys[user.id] ?: MemberKeyData() - - // Filter out distributions for members who are no longer in the space - val filteredOldDistributions = oldKeyData.distributions.filter { it.recipientId in memberIds } - - val rotatedKeyData = oldKeyData.copy( - memberDeviceId = deviceId, - distributions = newDistributions + filteredOldDistributions, - dataUpdatedAt = System.currentTimeMillis() - ) - - val updates = mapOf( - "memberKeys.${user.id}" to rotatedKeyData, - "docUpdatedAt" to System.currentTimeMillis() - ) - - transaction.update(docRef, updates) - }.await() - - Timber.d("Key rotation completed for space: $spaceId") - } } diff --git a/firestore.rules b/firestore.rules index 9f7db645..0a5cb351 100644 --- a/firestore.rules +++ b/firestore.rules @@ -190,7 +190,7 @@ service cloud.firestore { allow write: if false; } - match /spaces/{spaceId}/space_members/{member} { + match /spaces/{spaceId}/space_members/{docId} { allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); allow delete: if isAuthorized() && @@ -210,9 +210,7 @@ service cloud.firestore { (request.resource.data.role == 1 || request.resource.data.role == 2) && request.resource.data.location_enabled is bool && request.resource.data.created_at is int; - } - match /spaces/{spaceId}/space_members/{docId} { match /user_locations/{docId} { allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); From 25db65d0dae2efef397d476aeab9f85e225d9049 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 9 Jan 2025 11:39:14 +0530 Subject: [PATCH 4/7] PR changes --- .../com/canopas/yourspace/ui/MainViewModel.kt | 14 +-- .../yourspace/ui/component/OtpTextField.kt | 5 +- .../home/map/component/SelectedUserDetail.kt | 2 +- .../timeline/JourneyTimelineViewModel.kt | 10 +- .../ui/flow/pin/enterpin/EnterPinScreen.kt | 22 ++-- .../ui/flow/pin/enterpin/EnterPinViewModel.kt | 38 +++---- .../ui/flow/pin/setpin/SetPinScreen.kt | 23 +--- .../ui/flow/pin/setpin/SetPinViewModel.kt | 96 +++++++--------- app/src/main/res/values/strings.xml | 4 - data/build.gradle.kts | 1 + .../data/models/location/LocationJourney.kt | 105 ------------------ .../yourspace/data/models/space/ApiSpace.kt | 20 ++-- .../data/models/user/ApiSenderKeyRecord.kt | 21 +++- .../data/repository/SpaceRepository.kt | 1 - .../data/service/auth/AuthService.kt | 2 +- .../service/location/ApiJourneyService.kt | 17 ++- .../service/location/ApiLocationService.kt | 24 ++-- .../data/service/location/JourneyKtx.kt | 97 ++++++++++++++++ .../data/service/space/ApiSpaceService.kt | 28 +++-- .../data/service/user/ApiUserService.kt | 14 ++- .../BufferedSenderKeyStore.kt | 66 +++++------ .../bufferedkeystore/DistributionId.kt | 61 ---------- .../SignalServiceSenderKeyStore.kt | 21 +--- .../data/storage/database/SenderKeyEntity.kt | 13 ++- .../data/utils/EphemeralECDHUtils.kt | 10 +- .../yourspace/data/utils/PrivateKeyUtils.kt | 4 +- 26 files changed, 299 insertions(+), 420 deletions(-) create mode 100644 data/src/main/java/com/canopas/yourspace/data/service/location/JourneyKtx.kt delete mode 100644 data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/DistributionId.kt diff --git a/app/src/main/java/com/canopas/yourspace/ui/MainViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/MainViewModel.kt index 28c866dc..b221187c 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/MainViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/MainViewModel.kt @@ -44,18 +44,16 @@ class MainViewModel @Inject constructor( viewModelScope.launch { val currentUser = authService.getUser() val isExistingUser = currentUser != null - val showSetPinScreen = - isExistingUser && currentUser!!.identity_key_public?.toBytes() - .contentEquals(currentUser.identity_key_private?.toBytes()) - val showEnterPinScreen = - isExistingUser && currentUser!!.identity_key_public?.toBytes() - .contentEquals(currentUser.identity_key_private?.toBytes()) && userPreferences.getPasskey() - .isNullOrEmpty() + val identityKeysMatch = currentUser?.let { + it.identity_key_public?.toBytes().contentEquals(it.identity_key_private?.toBytes()) + } ?: false + val showSetPinScreen = isExistingUser && identityKeysMatch + val showEnterPinScreen = showSetPinScreen && userPreferences.getPasskey().isNullOrEmpty() val initialRoute = when { !userPreferences.isIntroShown() -> AppDestinations.intro.path userPreferences.currentUser == null -> AppDestinations.signIn.path - showSetPinScreen -> AppDestinations.setPin.path showEnterPinScreen -> AppDestinations.enterPin.path + showSetPinScreen -> AppDestinations.setPin.path !userPreferences.isOnboardShown() -> AppDestinations.onboard.path else -> AppDestinations.home.path } diff --git a/app/src/main/java/com/canopas/yourspace/ui/component/OtpTextField.kt b/app/src/main/java/com/canopas/yourspace/ui/component/OtpTextField.kt index 36e601c8..7b261d99 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/component/OtpTextField.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/component/OtpTextField.kt @@ -37,7 +37,8 @@ fun OtpInputField( pinText: String, onPinTextChange: (String) -> Unit, textStyle: TextStyle = AppTheme.appTypography.header2, - digitCount: Int = 6 + digitCount: Int = 6, + keyboardType: KeyboardType = KeyboardType.Text ) { val focusRequester = remember { FocusRequester() } BoxWithConstraints( @@ -55,7 +56,7 @@ fun OtpInputField( onPinTextChange(it) } }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), modifier = Modifier.focusRequester(focusRequester), decorationBox = { Row( diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/home/map/component/SelectedUserDetail.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/home/map/component/SelectedUserDetail.kt index e5aac09a..44650923 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/home/map/component/SelectedUserDetail.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/home/map/component/SelectedUserDetail.kt @@ -159,7 +159,7 @@ private fun MemberInfoView( val state by viewModel.state.collectAsState() var address by remember { mutableStateOf("") } - val time = timeAgo(location?.created_at ?: System.currentTimeMillis()) + val time = location?.created_at?.let { timeAgo(it) } ?: "" val userStateText = if (user.noNetwork) { stringResource(R.string.map_selected_user_item_no_network_state) } else if (user.locationPermissionDenied) { diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/timeline/JourneyTimelineViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/timeline/JourneyTimelineViewModel.kt index 367d3426..55b52e5d 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/timeline/JourneyTimelineViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/timeline/JourneyTimelineViewModel.kt @@ -96,7 +96,7 @@ class JourneyTimelineViewModel @Inject constructor( try { val from = _state.value.selectedTimeFrom val to = _state.value.selectedTimeTo - val lastJourneyTime = allJourneys.minOfOrNull { it.updated_at!! } + val lastJourneyTime = allJourneys.minOfOrNull { it.updated_at } val locations = if (loadMore) { journeyService.getMoreJourneyHistory(userId, lastJourneyTime) @@ -105,8 +105,8 @@ class JourneyTimelineViewModel @Inject constructor( } val filteredLocations = locations.filter { - (it.created_at?.let { created -> created in from..to } ?: false) || - (it.updated_at?.let { updated -> updated in from..to } ?: false) + it.created_at in from..to || + it.updated_at in from..to } val locationJourneys = (allJourneys + filteredLocations).groupByDate() @@ -158,12 +158,12 @@ class JourneyTimelineViewModel @Inject constructor( private fun List.groupByDate(): Map> { val journeys = this.distinctBy { it.id } - .sortedByDescending { it.updated_at!! } + .sortedByDescending { it.updated_at } val groupedItems = mutableMapOf>() for (journey in journeys) { - val date = getDayStartTimestamp(journey.created_at!!) + val date = getDayStartTimestamp(journey.created_at) if (!groupedItems.containsKey(date)) { groupedItems[date] = mutableListOf() diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinScreen.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinScreen.kt index 926143d3..e06e23b1 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinScreen.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinScreen.kt @@ -18,16 +18,14 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.canopas.yourspace.R import com.canopas.yourspace.ui.component.OtpInputField import com.canopas.yourspace.ui.component.PrimaryButton -import com.canopas.yourspace.ui.flow.pin.setpin.PinErrorState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -51,7 +49,6 @@ fun EnterPinScreen() { private fun EnterPinContent(modifier: Modifier) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() - val context = LocalContext.current Column( modifier = modifier @@ -81,21 +78,16 @@ private fun EnterPinContent(modifier: Modifier) { OtpInputField( pinText = state.pin, onPinTextChange = { viewModel.onPinChanged(it) }, - digitCount = 4 + digitCount = 4, + keyboardType = KeyboardType.Number ) Spacer(modifier = Modifier.height(16.dp)) - if (state.pinError != null) { - val pinErrorText = when (state.pinError) { - PinErrorState.LENGTH_ERROR -> context.getString(R.string.enter_pin_error_text_length) - PinErrorState.CHARACTERS_ERROR -> context.getString(R.string.enter_pin_error_characters_input) - PinErrorState.INVALID_PIN -> context.getString(R.string.enter_pin_invalid_pin_text) - else -> "" - } + if (state.isPinInvalid) { Text( - text = pinErrorText, - color = if (pinErrorText.isNotEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, + text = stringResource(R.string.enter_pin_invalid_pin_text), + color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) ) @@ -108,7 +100,7 @@ private fun EnterPinContent(modifier: Modifier) { onClick = { viewModel.processPin() }, - enabled = state.pin != "" && state.pinError == null, + enabled = state.pin != "" && !state.isPinInvalid && state.pin.length == 4, modifier = Modifier.fillMaxWidth(), showLoader = state.showLoader ) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinViewModel.kt index f2c7522b..5f8eb21c 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinViewModel.kt @@ -6,7 +6,6 @@ import com.canopas.yourspace.data.service.auth.AuthService import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.AppDispatcher import com.canopas.yourspace.domain.utils.ConnectivityObserver -import com.canopas.yourspace.ui.flow.pin.setpin.PinErrorState import com.canopas.yourspace.ui.navigation.AppDestinations import com.canopas.yourspace.ui.navigation.AppNavigator import dagger.hilt.android.lifecycle.HiltViewModel @@ -34,16 +33,7 @@ class EnterPinViewModel @Inject constructor( fun onPinChanged(newPin: String) { _state.value = _state.value.copy(pin = newPin) if (newPin.length == 4) { - _state.value = _state.value.copy(pinError = null) - } - } - - private fun validatePin() { - val pin = state.value.pin - if (pin.length < 4) { - _state.value = _state.value.copy(pinError = PinErrorState.LENGTH_ERROR, showLoader = false) - } else if (pin.length == 4 && !pin.all { it.isDigit() }) { - _state.value = _state.value.copy(pinError = PinErrorState.CHARACTERS_ERROR, showLoader = false) + _state.value = _state.value.copy(isPinInvalid = false) } } @@ -62,19 +52,17 @@ class EnterPinViewModel @Inject constructor( fun processPin() = viewModelScope.launch(appDispatcher.MAIN) { _state.value = _state.value.copy(showLoader = true) val pin = state.value.pin - validatePin() - if (state.value.pinError == null) { - val isPinValid = authService.validatePasskey(passKey = pin) - if (isPinValid) { - userPreferences.setOnboardShown(true) - navigator.navigateTo( - AppDestinations.home.path, - popUpToRoute = AppDestinations.signIn.path, - inclusive = true - ) - } else { - _state.value = _state.value.copy(pinError = PinErrorState.INVALID_PIN, showLoader = false) - } + val isPinValid = authService.validatePasskey(passKey = pin) + if (isPinValid) { + userPreferences.setOnboardShown(true) + navigator.navigateTo( + AppDestinations.home.path, + popUpToRoute = AppDestinations.signIn.path, + inclusive = true + ) + _state.value = _state.value.copy(showLoader = false) + } else { + _state.value = _state.value.copy(isPinInvalid = true, showLoader = false) } } } @@ -82,6 +70,6 @@ class EnterPinViewModel @Inject constructor( data class EnterPinScreenState( val showLoader: Boolean = false, val pin: String = "", - val pinError: PinErrorState? = null, + val isPinInvalid: Boolean = false, val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available ) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinScreen.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinScreen.kt index 55ead0f8..e1c08cd7 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinScreen.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinScreen.kt @@ -18,9 +18,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -51,7 +50,6 @@ fun SetPinScreen() { private fun SetPinContent(modifier: Modifier) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() - val context = LocalContext.current Column( modifier = modifier @@ -81,25 +79,12 @@ private fun SetPinContent(modifier: Modifier) { OtpInputField( pinText = state.pin, onPinTextChange = { viewModel.onPinChanged(it) }, - digitCount = 4 + digitCount = 4, + keyboardType = KeyboardType.Number ) Spacer(modifier = Modifier.height(16.dp)) - if (state.pinError != null) { - val pinErrorText = when (state.pinError) { - PinErrorState.LENGTH_ERROR -> context.getString(R.string.set_pin_error_text_length) - PinErrorState.CHARACTERS_ERROR -> context.getString(R.string.set_pin_error_characters_input) - else -> "" - } - Text( - text = pinErrorText, - color = if (pinErrorText.isNotEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp) - ) - } - Spacer(modifier = Modifier.height(24.dp)) PrimaryButton( @@ -107,7 +92,7 @@ private fun SetPinContent(modifier: Modifier) { onClick = { viewModel.processPin() }, - enabled = state.pin != "" && state.pinError == null, + enabled = state.enableButton, modifier = Modifier.fillMaxWidth(), showLoader = state.showLoader ) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt index 590d37b2..d1280f4a 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt @@ -17,12 +17,6 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import javax.inject.Inject -enum class PinErrorState { - LENGTH_ERROR, - CHARACTERS_ERROR, - INVALID_PIN -} - @HiltViewModel class SetPinViewModel @Inject constructor( private val navigator: AppNavigator, @@ -40,21 +34,7 @@ class SetPinViewModel @Inject constructor( } fun onPinChanged(newPin: String) { - _state.value = _state.value.copy(pin = newPin) - if (newPin.length == 4) { - _state.value = _state.value.copy(pinError = null) - } - } - - private fun validatePin() { - val pin = state.value.pin - if (pin.length < 4) { - _state.value = _state.value.copy(pinError = PinErrorState.LENGTH_ERROR, showLoader = false) - } else if (pin.length == 4 && !pin.all { it.isDigit() }) { - _state.value = _state.value.copy(pinError = PinErrorState.CHARACTERS_ERROR, showLoader = false) - } else { - _state.value = _state.value.copy(pinError = null) - } + _state.value = _state.value.copy(pin = newPin, enableButton = newPin.length == 4) } fun checkInternetConnection() { @@ -72,49 +52,49 @@ class SetPinViewModel @Inject constructor( fun processPin() = viewModelScope.launch(appDispatcher.MAIN) { _state.value = _state.value.copy(showLoader = true) val pin = state.value.pin - validatePin() - if (state.value.pinError == null) { - try { - authService.generateAndSaveUserKeys(passKey = pin) - val userId = authService.getUser()?.id - if (userId == null) { - _state.value = _state.value.copy( - error = IllegalStateException("Failed to get user ID after key generation") - ) - return@launch - } + try { + authService.generateAndSaveUserKeys(passKey = pin) + val userId = authService.getUser()?.id - val userSpaces = spaceRepository.getUserSpaces(userId) + if (userId == null) { + _state.value = _state.value.copy( + error = IllegalStateException("Failed to get user ID after key generation"), + showLoader = false + ) + return@launch + } - val userHasSpaces = - userSpaces.firstOrNull() != null && userSpaces.firstOrNull()?.isNotEmpty() == true - if (userHasSpaces) { - userPreferences.setOnboardShown(true) - try { - spaceRepository.generateAndDistributeSenderKeysForExistingSpaces( - spaceIds = userSpaces.firstOrNull()?.map { it.id } ?: emptyList() - ) - } catch (e: Exception) { - _state.value = _state.value.copy(error = e) - return@launch - } + val userSpaces = spaceRepository.getUserSpaces(userId) - navigator.navigateTo( - AppDestinations.home.path, - popUpToRoute = AppDestinations.signIn.path, - inclusive = true - ) - } else { - navigator.navigateTo( - AppDestinations.onboard.path, - popUpToRoute = AppDestinations.signIn.path, - inclusive = true + val userHasSpaces = + userSpaces.firstOrNull() != null && userSpaces.firstOrNull()?.isNotEmpty() == true + if (userHasSpaces) { + userPreferences.setOnboardShown(true) + try { + spaceRepository.generateAndDistributeSenderKeysForExistingSpaces( + spaceIds = userSpaces.firstOrNull()?.map { it.id } ?: emptyList() ) + } catch (e: Exception) { + _state.value = _state.value.copy(error = e, showLoader = false) + return@launch } - } catch (e: Exception) { - _state.value = _state.value.copy(error = e, showLoader = false) + + navigator.navigateTo( + AppDestinations.home.path, + popUpToRoute = AppDestinations.signIn.path, + inclusive = true + ) + } else { + navigator.navigateTo( + AppDestinations.onboard.path, + popUpToRoute = AppDestinations.signIn.path, + inclusive = true + ) } + _state.value = _state.value.copy(showLoader = false) + } catch (e: Exception) { + _state.value = _state.value.copy(error = e, showLoader = false) } } } @@ -122,7 +102,7 @@ class SetPinViewModel @Inject constructor( data class EnterPinScreenState( val showLoader: Boolean = false, val pin: String = "", - val pinError: PinErrorState? = null, + val enableButton: Boolean = false, val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available, val error: Throwable? = null ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42a61274..1e2f7a3b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -314,15 +314,11 @@ Secure your account by setting a 4-digit PIN Your PIN ensures that only you can access your account Set Pin - Pin must be at least 4 characters - Please enter only numbers Enter Your PIN Enter your 4-digit PIN to access your account Your PIN ensures that only you can access your account Incorrect PIN. Please try again - Pin must be at least 4 characters - Please enter only numbers Continue \ No newline at end of file diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 8ef13102..cd6ab7fe 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -73,6 +73,7 @@ dependencies { // Moshi implementation("com.squareup.moshi:moshi-kotlin:1.15.0") + ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0") // Timber implementation("com.jakewharton.timber:timber:5.0.1") diff --git a/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt b/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt index 6af4ac93..038b3daa 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/location/LocationJourney.kt @@ -5,7 +5,6 @@ import androidx.annotation.Keep import com.google.android.gms.maps.model.LatLng import com.google.firebase.firestore.Blob import com.squareup.moshi.JsonClass -import org.signal.libsignal.protocol.groups.GroupCipher import java.util.UUID @Keep @@ -99,111 +98,7 @@ fun LocationJourney.toLocationFromMovingJourney() = Location("").apply { longitude = this@toLocationFromMovingJourney.to_longitude ?: 0.0 } -fun Location.toLocationJourney(userId: String, journeyId: String) = LocationJourney( - id = journeyId, - user_id = userId, - from_latitude = latitude, - from_longitude = longitude -) - enum class JourneyType { MOVING, STEADY } - -/** - * Convert an [EncryptedLocationJourney] to a [LocationJourney] using the provided [GroupCipher] - */ -fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher): LocationJourney { - val decryptedFromLat = groupCipher.decrypt(from_latitude.toBytes()) - val decryptedFromLong = groupCipher.decrypt(from_longitude.toBytes()) - val decryptedToLat = to_latitude?.let { groupCipher.decrypt(it.toBytes()) } - val decryptedToLong = to_longitude?.let { groupCipher.decrypt(it.toBytes()) } - - val decryptedRoutes = routes.map { - JourneyRoute( - latitude = groupCipher.decrypt(it.latitude.toBytes()) - .toString(Charsets.UTF_8).toDouble(), - longitude = groupCipher.decrypt(it.longitude.toBytes()) - .toString(Charsets.UTF_8).toDouble() - ) - } - - return LocationJourney( - id = id, - user_id = user_id, - from_latitude = decryptedFromLat.toString(Charsets.UTF_8).toDouble(), - from_longitude = decryptedFromLong.toString(Charsets.UTF_8).toDouble(), - to_latitude = decryptedToLat?.toString(Charsets.UTF_8)?.toDouble(), - to_longitude = decryptedToLong?.toString(Charsets.UTF_8)?.toDouble(), - route_distance = route_distance, - route_duration = route_duration, - routes = decryptedRoutes, - created_at = created_at, - updated_at = updated_at, - type = type, - key_id = key_id - ) -} - -/** - * Convert a [LocationJourney] to an [EncryptedLocationJourney] using the provided [GroupCipher] - */ -fun LocationJourney.toEncryptedLocationJourney( - groupCipher: GroupCipher, - distributionId: UUID -): EncryptedLocationJourney { - val encryptedFromLat = groupCipher.encrypt( - distributionId, - from_latitude.toString().toByteArray(Charsets.UTF_8) - ) - val encryptedFromLong = groupCipher.encrypt( - distributionId, - from_longitude.toString().toByteArray(Charsets.UTF_8) - ) - val encryptedToLat = to_latitude?.let { - groupCipher.encrypt( - distributionId, - it.toString().toByteArray(Charsets.UTF_8) - ) - } - val encryptedToLong = to_longitude?.let { - groupCipher.encrypt( - distributionId, - it.toString().toByteArray(Charsets.UTF_8) - ) - } - - val encryptedRoutes = routes.map { - EncryptedJourneyRoute( - latitude = Blob.fromBytes( - groupCipher.encrypt( - distributionId, - it.latitude.toString().toByteArray(Charsets.UTF_8) - ).serialize() - ), - longitude = Blob.fromBytes( - groupCipher.encrypt( - distributionId, - it.longitude.toString().toByteArray(Charsets.UTF_8) - ).serialize() - ) - ) - } - - return EncryptedLocationJourney( - id = id, - user_id = user_id, - from_latitude = Blob.fromBytes(encryptedFromLat.serialize()), - from_longitude = Blob.fromBytes(encryptedFromLong.serialize()), - to_latitude = encryptedToLat?.let { Blob.fromBytes(it.serialize()) }, - to_longitude = encryptedToLong?.let { Blob.fromBytes(it.serialize()) }, - route_distance = route_distance, - route_duration = route_duration, - routes = encryptedRoutes, - created_at = created_at, - updated_at = updated_at, - type = type, - key_id = key_id - ) -} diff --git a/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt b/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt index c584b134..8c52b010 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/space/ApiSpace.kt @@ -53,8 +53,8 @@ data class ApiSpaceInvitation( */ @Keep data class GroupKeysDoc( - val docUpdatedAt: Long = System.currentTimeMillis(), // To be updated whenever users are added/removed - val memberKeys: Map = emptyMap() + val doc_updated_at: Long = System.currentTimeMillis(), // To be updated whenever users are added/removed + val member_keys: Map = emptyMap() ) /** @@ -63,9 +63,9 @@ data class GroupKeysDoc( */ @Keep data class MemberKeyData( - val memberDeviceId: Int = 0, + val member_device_id: Int = 0, val distributions: List = emptyList(), - val dataUpdatedAt: Long = System.currentTimeMillis() // To be updated whenever a new distribution is added + val data_updated_at: Long = System.currentTimeMillis() // To be updated whenever a new distribution is added ) /** @@ -75,19 +75,19 @@ data class MemberKeyData( */ data class EncryptedDistribution( val id: String = UUID.randomUUID().toString(), - val recipientId: String = "", - val ephemeralPub: Blob = Blob.fromBytes(ByteArray(0)), // 32 bytes - val iv: Blob = Blob.fromBytes(ByteArray(0)), // 12 bytes + val recipient_id: String = "", + val ephemeral_pub: Blob = Blob.fromBytes(ByteArray(0)), // 33 bytes (compressed distribution key) + val iv: Blob = Blob.fromBytes(ByteArray(0)), // 16 bytes val ciphertext: Blob = Blob.fromBytes(ByteArray(0)), // AES/GCM ciphertext - val createdAt: Long = System.currentTimeMillis() + val created_at: Long = System.currentTimeMillis() ) { init { validateFieldSizes() } private fun validateFieldSizes() { - require(ephemeralPub.toBytes().size == 33 || ephemeralPub.toBytes().isEmpty()) { - "Invalid size for ephemeralPub: expected 33 bytes, got ${ephemeralPub.toBytes().size} bytes." + require(ephemeral_pub.toBytes().size == 33 || ephemeral_pub.toBytes().isEmpty()) { + "Invalid size for ephemeralPub: expected 33 bytes, got ${ephemeral_pub.toBytes().size} bytes." } require(iv.toBytes().size == 16 || iv.toBytes().isEmpty()) { "Invalid size for iv: expected 16 bytes, got ${iv.toBytes().size} bytes." diff --git a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiSenderKeyRecord.kt b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiSenderKeyRecord.kt index 64fbbeff..2c1cdf08 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiSenderKeyRecord.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiSenderKeyRecord.kt @@ -5,13 +5,26 @@ import com.google.firebase.firestore.Blob import com.squareup.moshi.JsonClass import java.util.UUID +/** +* Represents a sender key record for Signal Protocol implementation. + * @property id Unique identifier for the record. + * @property address The sender's address in Signal Protocol format, relatively spaceId for our use case. + * @property device_id The sender's device ID(must be positive). + * @property distribution_id The distribution ID for the sender key. - A random UUID. + * @property record The actual sender key record. + * @property created_at The timestamp when the record was created. +* */ @Keep @JsonClass(generateAdapter = true) data class ApiSenderKeyRecord( val id: String = UUID.randomUUID().toString(), val address: String = "", - val deviceId: Int = 0, - val distributionId: String = "", + val device_id: Int = 0, + val distribution_id: String = "", val record: Blob = Blob.fromBytes(ByteArray(0)), - val createdAt: Long = System.currentTimeMillis() -) + val created_at: Long = System.currentTimeMillis() +) { + init { + require(device_id > 0) { "Device ID must be non-negative." } + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/repository/SpaceRepository.kt b/data/src/main/java/com/canopas/yourspace/data/repository/SpaceRepository.kt index e85168d2..fd32ea30 100644 --- a/data/src/main/java/com/canopas/yourspace/data/repository/SpaceRepository.kt +++ b/data/src/main/java/com/canopas/yourspace/data/repository/SpaceRepository.kt @@ -268,7 +268,6 @@ class SpaceRepository @Inject constructor( spaceService.generateAndDistributeSenderKeysForExistingSpaces(spaceIds) } catch (e: Exception) { Timber.e(e, "Failed to generate and distribute sender keys") - throw e } } } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/auth/AuthService.kt b/data/src/main/java/com/canopas/yourspace/data/service/auth/AuthService.kt index 26bcbd53..f76e081d 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/auth/AuthService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/auth/AuthService.kt @@ -115,7 +115,7 @@ class AuthService @Inject constructor( locationManager.stopService() locationCache.clear() } catch (e: Exception) { - throw SecurityException("Failed to completely sign out. Some sensitive data might not be cleared.") + throw SecurityException("Failed to completely sign out. Some sensitive data might not be cleared.", e) } } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt index ba4be5b5..196ac27b 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt @@ -2,8 +2,6 @@ package com.canopas.yourspace.data.service.location import com.canopas.yourspace.data.models.location.EncryptedLocationJourney import com.canopas.yourspace.data.models.location.LocationJourney -import com.canopas.yourspace.data.models.location.toDecryptedLocationJourney -import com.canopas.yourspace.data.models.location.toEncryptedLocationJourney import com.canopas.yourspace.data.models.space.GroupKeysDoc import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.storage.UserPreferences @@ -70,20 +68,21 @@ class ApiJourneyService @Inject constructor( keyId: String? = null, groupKeysDoc: GroupKeysDoc ): Triple? { - val memberKeysData = groupKeysDoc.memberKeys[userId] ?: return null + val memberKeysData = groupKeysDoc.member_keys[userId] ?: return null val distribution = memberKeysData.distributions - .sortedByDescending { it.createdAt } + .sortedByDescending { it.created_at } .firstOrNull { - it.recipientId == userId && (keyId == null || it.id == keyId) + it.recipient_id == userId && (keyId == null || it.id == keyId) } ?: return null - val privateKey = getCurrentUserPrivateKey(userPreferences.currentUser!!) ?: return null + val currentUser = userPreferences.currentUser ?: return null + val privateKey = getCurrentUserPrivateKey(currentUser) ?: return null // Decrypt the distribution message val decryptedBytes = EphemeralECDHUtils.decrypt(distribution, privateKey) ?: return null val distributionMessage = SenderKeyDistributionMessage(decryptedBytes) - val groupAddress = SignalProtocolAddress(spaceId, memberKeysData.memberDeviceId) + val groupAddress = SignalProtocolAddress(spaceId, memberKeysData.member_device_id) // Ensures the distribution ID is loaded into the store bufferedSenderKeyStore.loadSenderKey(groupAddress, distributionMessage.distributionId) @@ -105,9 +104,9 @@ class ApiJourneyService @Inject constructor( userId: String, groupKeysDoc: GroupKeysDoc, keyId: String? = null, - defaultValue: T, + defaultValue: T?, crossinline block: (cipher: GroupCipher) -> T? - ): T { + ): T? { val (_, groupCipher) = getGroupCipherByKeyId(spaceId, userId, keyId, groupKeysDoc) ?: return defaultValue return try { diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt index b5c8a4ba..bbbee0c7 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiLocationService.kt @@ -214,12 +214,12 @@ class ApiLocationService @Inject constructor( val snapshot = spaceGroupKeysRef(spaceId).get().await() val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: return null - val memberKeyData = groupKeysDoc.memberKeys[userId] ?: return null + val memberKeyData = groupKeysDoc.member_keys[userId] ?: return null val distribution = memberKeyData.distributions.sortedByDescending { - it.createdAt + it.created_at }.firstOrNull { - it.recipientId == currentUser.id + it.recipient_id == currentUser.id } ?: return null val currentUserPrivateKey = getCurrentUserPrivateKey(currentUser) ?: return null @@ -231,14 +231,14 @@ class ApiLocationService @Inject constructor( } val distributionMessage = SenderKeyDistributionMessage(decryptedDistribution) - val groupAddress = SignalProtocolAddress(spaceId, memberKeyData.memberDeviceId) + val groupAddress = SignalProtocolAddress(spaceId, memberKeyData.member_device_id) bufferedSenderKeyStore.loadSenderKey(groupAddress, distributionMessage.distributionId) // If the sender key data is outdated, we need to distribute the sender key to the pending users - if (memberKeyData.dataUpdatedAt < groupKeysDoc.docUpdatedAt && canDistributeSenderKey) { + if (memberKeyData.data_updated_at < groupKeysDoc.doc_updated_at && canDistributeSenderKey) { // Here means the sender key data is outdated, so we need to distribute the sender key to the users. - rotateSenderKey(spaceId = spaceId, deviceId = memberKeyData.memberDeviceId) + rotateSenderKey(spaceId = spaceId, deviceId = memberKeyData.member_device_id) } return try { @@ -306,21 +306,21 @@ class ApiLocationService @Inject constructor( val snapshot = transaction.get(docRef) val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() - val oldKeyData = groupKeysDoc.memberKeys[user.id] ?: MemberKeyData() + val oldKeyData = groupKeysDoc.member_keys[user.id] ?: MemberKeyData() // Filter out distributions for members who are no longer in the space val filteredOldDistributions = - oldKeyData.distributions.filter { it.recipientId in memberIds } + oldKeyData.distributions.filter { it.recipient_id in memberIds } val rotatedKeyData = oldKeyData.copy( - memberDeviceId = deviceId, + member_device_id = deviceId, distributions = newDistributions + filteredOldDistributions, - dataUpdatedAt = System.currentTimeMillis() + data_updated_at = System.currentTimeMillis() ) val updates = mapOf( - "memberKeys.${user.id}" to rotatedKeyData, - "docUpdatedAt" to System.currentTimeMillis() + "member_keys.${user.id}" to rotatedKeyData, + "doc_updated_at" to System.currentTimeMillis() ) transaction.update(docRef, updates) diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/JourneyKtx.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/JourneyKtx.kt new file mode 100644 index 00000000..b107a273 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/JourneyKtx.kt @@ -0,0 +1,97 @@ +package com.canopas.yourspace.data.service.location + +import com.canopas.yourspace.data.models.location.EncryptedJourneyRoute +import com.canopas.yourspace.data.models.location.EncryptedLocationJourney +import com.canopas.yourspace.data.models.location.JourneyRoute +import com.canopas.yourspace.data.models.location.LocationJourney +import com.google.firebase.firestore.Blob +import org.signal.libsignal.protocol.groups.GroupCipher +import timber.log.Timber +import java.util.UUID + +/** + * Convert an [EncryptedLocationJourney] to a [LocationJourney] using the provided [GroupCipher] + */ +fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher): LocationJourney { + val decryptedFromLat = groupCipher.decrypt(from_latitude) + val decryptedFromLong = groupCipher.decrypt(from_longitude) + val decryptedToLat = to_latitude?.let { groupCipher.decrypt(it) } + val decryptedToLong = to_longitude?.let { groupCipher.decrypt(it) } + + val decryptedRoutes = routes.map { + JourneyRoute( + latitude = groupCipher.decrypt(it.latitude), + longitude = groupCipher.decrypt(it.longitude) + ) + } + + return LocationJourney( + id = id, + user_id = user_id, + from_latitude = decryptedFromLat, + from_longitude = decryptedFromLong, + to_latitude = decryptedToLat, + to_longitude = decryptedToLong, + route_distance = route_distance, + route_duration = route_duration, + routes = decryptedRoutes, + created_at = created_at, + updated_at = updated_at, + type = type, + key_id = key_id + ) +} + +/** + * Convert a [LocationJourney] to an [EncryptedLocationJourney] using the provided [GroupCipher] + */ +fun LocationJourney.toEncryptedLocationJourney( + groupCipher: GroupCipher, + distributionId: UUID +): EncryptedLocationJourney { + val encryptedFromLat = groupCipher.encrypt(distributionId, from_latitude) + val encryptedFromLong = groupCipher.encrypt(distributionId, from_longitude) + val encryptedToLat = to_latitude?.let { groupCipher.encrypt(distributionId, it) } + val encryptedToLong = to_longitude?.let { groupCipher.encrypt(distributionId, it) } + + val encryptedRoutes = routes.map { + EncryptedJourneyRoute( + latitude = groupCipher.encrypt(distributionId, it.latitude), + longitude = groupCipher.encrypt(distributionId, it.longitude) + ) + } + + return EncryptedLocationJourney( + id = id, + user_id = user_id, + from_latitude = encryptedFromLat, + from_longitude = encryptedFromLong, + to_latitude = encryptedToLat, + to_longitude = encryptedToLong, + route_distance = route_distance, + route_duration = route_duration, + routes = encryptedRoutes, + created_at = created_at, + updated_at = updated_at, + type = type, + key_id = key_id + ) +} + +fun GroupCipher.decrypt(data: Blob): Double { + return try { + decrypt(data.toBytes()).toString(Charsets.UTF_8).toDouble() + } catch (e: Exception) { + Timber.e(e, "Failed to decrypt double") + 0.0 + } +} + +fun GroupCipher.encrypt(distributionId: UUID, data: Double): Blob { + return try { + Blob.fromBytes(encrypt(distributionId, data.toString().toByteArray(Charsets.UTF_8)).serialize()) + } catch (e: Exception) { + Timber.e(e, "Failed to encrypt double") + Blob.fromBytes(ByteArray(0)) + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt index ed96a665..91191031 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/space/ApiSpaceService.kt @@ -16,6 +16,7 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_GROUP_ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.canopas.yourspace.data.utils.EphemeralECDHUtils import com.canopas.yourspace.data.utils.snapshotFlow +import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await import org.signal.libsignal.protocol.SignalProtocolAddress @@ -25,6 +26,7 @@ import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton +import kotlin.jvm.Throws @Singleton class ApiSpaceService @Inject constructor( @@ -68,8 +70,9 @@ class ApiSpaceService @Inject constructor( return spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) } + @Throws(IllegalStateException::class) suspend fun joinSpace(spaceId: String, role: Int = SPACE_MEMBER_ROLE_MEMBER) { - val user = authService.currentUser ?: return + val user = authService.currentUser ?: throw IllegalStateException("No authenticated user") spaceMemberRef(spaceId) .document(user.id).also { val member = ApiSpaceMember( @@ -86,7 +89,7 @@ class ApiSpaceService @Inject constructor( // Update the "docUpdatedAt" so others see membership changed val docRef = spaceGroupKeysDoc(spaceId) - docRef.update("docUpdatedAt", System.currentTimeMillis()).await() + docRef.update("doc_updated_at", System.currentTimeMillis()).await() // Distribute sender key to all members distributeSenderKeyToSpaceMembers(spaceId, user.id) @@ -111,12 +114,12 @@ class ApiSpaceService @Inject constructor( val publicKey = try { val publicKeyBytes = publicBlob.toBytes() if (publicKeyBytes.size != 33) { // Expected size for compressed EC public key - Timber.e("Invalid public key size for member ${member.user_id}") + Timber.e("Invalid public key size for a space member") continue } Curve.decodePoint(publicKeyBytes, 0) } catch (e: Exception) { - Timber.e(e, "Failed to decode public key for member ${member.user_id}") + Timber.e(e, "Failed to decode public key for a space member") continue } @@ -128,15 +131,15 @@ class ApiSpaceService @Inject constructor( val docRef = spaceGroupKeysDoc(spaceId) val snapshot = transaction.get(docRef) val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() - val oldMemberKeyData = groupKeysDoc.memberKeys[senderUserId] ?: MemberKeyData() + val oldMemberKeyData = groupKeysDoc.member_keys[senderUserId] ?: MemberKeyData() val newMemberKeyData = oldMemberKeyData.copy( - memberDeviceId = deviceId.toInt(), + member_device_id = deviceId.toInt(), distributions = distributions, - dataUpdatedAt = System.currentTimeMillis() + data_updated_at = System.currentTimeMillis() ) val updates = mapOf( - "memberKeys.$senderUserId" to newMemberKeyData, - "docUpdatedAt" to System.currentTimeMillis() + "member_keys.$senderUserId" to newMemberKeyData, + "doc_updated_at" to System.currentTimeMillis() ) transaction.update(docRef, updates) }.await() @@ -190,7 +193,12 @@ class ApiSpaceService @Inject constructor( // Update the "docUpdatedAt" so others see membership changed and remove sender key for the removed user val docRef = spaceGroupKeysDoc(spaceId) - docRef.update("docUpdatedAt", System.currentTimeMillis()).await() + docRef.update( + mapOf( + "doc_updated_at" to System.currentTimeMillis(), + "member_keys.$userId" to FieldValue.delete() + ) + ).await() } suspend fun updateSpace(space: ApiSpace) { diff --git a/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt b/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt index 97cddcbe..d87042c4 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt @@ -9,7 +9,6 @@ import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_USERS import com.canopas.yourspace.data.utils.Device -import com.canopas.yourspace.data.utils.EncryptionException import com.canopas.yourspace.data.utils.PrivateKeyUtils import com.canopas.yourspace.data.utils.snapshotFlow import com.google.android.gms.auth.api.signin.GoogleSignInAccount @@ -27,9 +26,9 @@ import org.signal.libsignal.protocol.IdentityKey import org.signal.libsignal.protocol.IdentityKeyPair import org.signal.libsignal.protocol.ecc.Curve import timber.log.Timber +import java.security.SecureRandom import javax.inject.Inject import javax.inject.Singleton -import kotlin.random.Random const val NETWORK_STATUS_CHECK_INTERVAL = 3 * 60 * 1000 @@ -50,9 +49,10 @@ class ApiUserService @Inject constructor( suspend fun getUser(userId: String): ApiUser? { return try { userRef.document(userId).get().await().toObject(ApiUser::class.java)?.let { user -> - if (user.id != currentUser?.id) return user + if (currentUser == null || user.id != currentUser.id) return user val decryptedPrivateKey = decryptPrivateKey(user) ?: return@let user - user.copy(identity_key_private = Blob.fromBytes(decryptedPrivateKey)) + userPreferences.storePrivateKey(decryptedPrivateKey) + user } } catch (e: Exception) { Timber.e(e, "Error while getting user") @@ -121,7 +121,7 @@ class ApiUserService @Inject constructor( suspend fun generateAndSaveUserKeys(user: ApiUser, passKey: String): ApiUser { val identityKeyPair = generateIdentityKeyPair() - val salt = ByteArray(16).apply { Random.nextBytes(this) } + val salt = ByteArray(16).apply { SecureRandom().nextBytes(this) } val encryptedPrivateKey = PrivateKeyUtils.encryptPrivateKey( identityKeyPair.privateKey.serialize(), passkey = passKey, @@ -132,12 +132,14 @@ class ApiUserService @Inject constructor( userRef.document(user.id).update( mapOf( + "updated_at" to System.currentTimeMillis(), "identity_key_public" to Blob.fromBytes(identityKeyPair.publicKey.publicKey.serialize()), "identity_key_private" to Blob.fromBytes(encryptedPrivateKey), "identity_key_salt" to Blob.fromBytes(salt) ) ).await() return user.copy( + updated_at = System.currentTimeMillis(), identity_key_public = Blob.fromBytes(identityKeyPair.publicKey.publicKey.serialize()), identity_key_private = Blob.fromBytes(identityKeyPair.privateKey.serialize()), identity_key_salt = Blob.fromBytes(salt) @@ -174,7 +176,7 @@ class ApiUserService @Inject constructor( val passkey = pin ?: userPreferences.getPasskey() ?: return null val decrypted = PrivateKeyUtils.decryptPrivateKey(encryptedPrivateKey, salt, passkey) decrypted - } catch (e: EncryptionException) { + } catch (e: Exception) { Timber.e(e, "Failed to decrypt private key for user ${user.id}") null } diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt index 947aed2c..3ed29b5e 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt @@ -10,6 +10,7 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBER import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_USER_SENDER_KEY_RECORD import com.google.firebase.firestore.Blob import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -45,12 +46,11 @@ class BufferedSenderKeyStore @Inject constructor( .collection(FIRESTORE_COLLECTION_USER_SENDER_KEY_RECORD) private val inMemoryStore: MutableMap = mutableMapOf() - private val sharedWithAddresses: MutableSet = mutableSetOf() private suspend fun saveSenderKeyToServer(senderKeyRecord: ApiSenderKeyRecord) { try { val currentUser = userPreferences.currentUser ?: return - val uniqueDocId = "${senderKeyRecord.deviceId}-${senderKeyRecord.distributionId}" + val uniqueDocId = "${senderKeyRecord.device_id}-${senderKeyRecord.distribution_id}" spaceSenderKeyRecordRef(senderKeyRecord.address, currentUser.id) .document(uniqueDocId).set(senderKeyRecord).await() } catch (e: Exception) { @@ -81,8 +81,8 @@ class BufferedSenderKeyStore @Inject constructor( val senderKeyRecord = ApiSenderKeyRecord( address = sender.name, - deviceId = sender.deviceId, - distributionId = distributionId.toString(), + device_id = sender.deviceId, + distribution_id = distributionId.toString(), record = Blob.fromBytes(record.serialize()) ) saveSenderKeyToServer(senderKeyRecord) @@ -91,25 +91,42 @@ class BufferedSenderKeyStore @Inject constructor( override fun loadSenderKey(sender: SignalProtocolAddress, distributionId: UUID): SenderKeyRecord? { val key = StoreKey(sender, distributionId, sender.deviceId) - return inMemoryStore[key] ?: runBlocking { - senderKeyDao.getSenderKeyRecord( - address = sender.name, - deviceId = sender.deviceId, - distributionId = distributionId.toString() - )?.let { - inMemoryStore[key] = it - it - } ?: fetchSenderKeyFromServer(sender)?.also { - inMemoryStore[key] = it + + return inMemoryStore[key] ?: kotlin.run { + val deferred = CompletableDeferred() + CoroutineScope(appDispatcher.IO).launch { + try { + val senderKeyRecord = senderKeyDao.getSenderKeyRecord( + address = sender.name, + deviceId = sender.deviceId, + distributionId = distributionId.toString() + )?.also { + inMemoryStore[key] = it + } ?: fetchSenderKeyFromServer(sender, distributionId)?.also { + inMemoryStore[key] = it + } + deferred.complete(senderKeyRecord) + } catch (e: Exception) { + deferred.completeExceptionally(e) + } + } + runBlocking { + try { + deferred.await() + } catch (e: Exception) { + Timber.e(e, "Error loading sender key") + null + } } } } - private suspend fun fetchSenderKeyFromServer(sender: SignalProtocolAddress): SenderKeyRecord? { + private suspend fun fetchSenderKeyFromServer(sender: SignalProtocolAddress, distributionId: UUID): SenderKeyRecord? { val currentUser = userPreferences.currentUser ?: return null return try { spaceSenderKeyRecordRef(sender.name.toString(), currentUser.id) - .whereEqualTo("deviceId", sender.deviceId) + .whereEqualTo("device_id", sender.deviceId) + .whereEqualTo("distribution_id", distributionId.toString()) .get() .await() .documents @@ -129,22 +146,5 @@ class BufferedSenderKeyStore @Inject constructor( } } - override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet { - throw UnsupportedOperationException("Should not happen during the intended usage pattern of this class") - } - - override fun markSenderKeySharedWith( - distributionId: DistributionId?, - addresses: Collection? - ) { - throw UnsupportedOperationException("Should not happen during the intended usage pattern of this class") - } - - override fun clearSenderKeySharedWith(addresses: Collection?) { - addresses?.forEach { address -> - address?.let { sharedWithAddresses.remove(it) } - } - } - data class StoreKey(val address: SignalProtocolAddress, val distributionId: UUID, val senderDeviceId: Int) } diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/DistributionId.kt b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/DistributionId.kt deleted file mode 100644 index 98547e40..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/DistributionId.kt +++ /dev/null @@ -1,61 +0,0 @@ -package com.canopas.yourspace.data.storage.bufferedkeystore - -import java.util.Objects -import java.util.UUID - -/** - * Represents the distributionId that is used to identify this group's sender key session. - * - * This is just a UUID, but we wrap it in order to provide some type safety and limit confusion - * around the multiple UUIDs we throw around. - */ -class DistributionId private constructor(private val uuid: UUID) { - /** - * Some devices appear to have a bad UUID.toString() that misrenders an all-zero UUID as "0000-0000". - * To account for this, we will keep our own string value, to prevent queries from going awry and such. - */ - private var stringValue: String? = null - - init { - if (uuid.leastSignificantBits == 0L && uuid.mostSignificantBits == 0L) { - this.stringValue = MY_STORY_STRING - } else { - this.stringValue = uuid.toString() - } - } - - fun asUuid(): UUID { - return uuid - } - - override fun toString(): String { - return stringValue!! - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other == null || javaClass != other.javaClass) return false - val that = other as DistributionId - return uuid == that.uuid - } - - override fun hashCode(): Int { - return Objects.hash(uuid) - } - - companion object { - private const val MY_STORY_STRING = "00000000-0000-0000-0000-000000000000" - - fun from(id: String?): DistributionId { - return DistributionId(UUID.fromString(id)) - } - - fun from(uuid: UUID): DistributionId { - return DistributionId(uuid) - } - - fun create(): DistributionId { - return DistributionId(UUID.randomUUID()) - } - } -} diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt index 0ec2bc41..771ad373 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt @@ -1,28 +1,9 @@ package com.canopas.yourspace.data.storage.bufferedkeystore -import org.signal.libsignal.protocol.SignalProtocolAddress import org.signal.libsignal.protocol.groups.state.SenderKeyStore /** * And extension of the normal protocol sender key store interface that has additional methods that are * needed in the service layer, but not the protocol layer. */ -interface SignalServiceSenderKeyStore : SenderKeyStore { - /** - * @return A set of protocol addresses that have previously been sent the sender key data for the provided distributionId. - */ - fun getSenderKeySharedWith(distributionId: DistributionId?): Set? - - /** - * Marks the provided addresses as having been sent the sender key data for the provided distributionId. - */ - fun markSenderKeySharedWith( - distributionId: DistributionId?, - addresses: Collection? - ) - - /** - * Marks the provided addresses as not knowing about any distributionIds. - */ - fun clearSenderKeySharedWith(addresses: Collection?) -} +interface SignalServiceSenderKeyStore : SenderKeyStore diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyEntity.kt b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyEntity.kt index ca21e7af..db63bca7 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyEntity.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyEntity.kt @@ -4,13 +4,18 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -@Entity(tableName = "sender_keys") +@Entity( + tableName = "sender_keys", + indices = [ + androidx.room.Index(value = ["address", "device_id", "distribution_id"], unique = true) + ] +) data class SenderKeyEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, - @ColumnInfo(name = "address") val address: String, + @ColumnInfo(name = "address", collate = ColumnInfo.NOCASE) val address: String, @ColumnInfo(name = "device_id") val deviceId: Int, - @ColumnInfo(name = "distribution_id") val distributionId: String, - @ColumnInfo(name = "record") val record: ByteArray, + @ColumnInfo(name = "distribution_id", collate = ColumnInfo.NOCASE) val distributionId: String, + @ColumnInfo(name = "record", typeAffinity = ColumnInfo.BLOB) val record: ByteArray, @ColumnInfo(name = "created_at") val createdAt: Long = System.currentTimeMillis() ) { override fun equals(other: Any?): Boolean { diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt b/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt index e86697a1..da0f7c20 100644 --- a/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt +++ b/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt @@ -49,8 +49,8 @@ object EphemeralECDHUtils { val cipherText = cipher.doFinal(plaintext) return EncryptedDistribution( - recipientId = receiverId, - ephemeralPub = Blob.fromBytes(ephemeralKeyPair.publicKey.serialize()), + recipient_id = receiverId, + ephemeral_pub = Blob.fromBytes(ephemeralKeyPair.publicKey.serialize()), iv = Blob.fromBytes(syntheticIv), ciphertext = Blob.fromBytes(cipherText) ) @@ -70,7 +70,7 @@ object EphemeralECDHUtils { return try { val syntheticIv = message.iv.toBytes() val cipherText = message.ciphertext.toBytes() - val ephemeralPublic = Curve.decodePoint(message.ephemeralPub.toBytes(), 0) + val ephemeralPublic = Curve.decodePoint(message.ephemeral_pub.toBytes(), 0) val masterSecret = Curve.calculateAgreement(ephemeralPublic, receiverPrivateKey) val mac = Mac.getInstance("HmacSHA256") @@ -135,10 +135,10 @@ object EphemeralECDHUtils { val keyMac = Mac.getInstance("HmacSHA256") keyMac.init(SecretKeySpec(masterSecret, "HmacSHA256")) - val cipherKeyKey: ByteArray = keyMac.doFinal(input) + val cipherKey: ByteArray = keyMac.doFinal(input) val cipherMac = Mac.getInstance("HmacSHA256") - cipherMac.init(SecretKeySpec(cipherKeyKey, "HmacSHA256")) + cipherMac.init(SecretKeySpec(cipherKey, "HmacSHA256")) return cipherMac.doFinal(syntheticIv) } } diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/PrivateKeyUtils.kt b/data/src/main/java/com/canopas/yourspace/data/utils/PrivateKeyUtils.kt index a4435329..20855ef0 100644 --- a/data/src/main/java/com/canopas/yourspace/data/utils/PrivateKeyUtils.kt +++ b/data/src/main/java/com/canopas/yourspace/data/utils/PrivateKeyUtils.kt @@ -1,13 +1,13 @@ package com.canopas.yourspace.data.utils import timber.log.Timber +import java.security.SecureRandom import javax.crypto.Cipher import javax.crypto.SecretKey import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec -import kotlin.random.Random private const val AES_ALGORITHM = "AES/GCM/NoPadding" private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256" @@ -42,7 +42,7 @@ object PrivateKeyUtils { private fun encryptData(data: ByteArray, key: SecretKey): ByteArray { return try { val cipher = Cipher.getInstance(AES_ALGORITHM) - val iv = ByteArray(GCM_IV_SIZE).apply { Random.nextBytes(this) } + val iv = ByteArray(GCM_IV_SIZE).apply { SecureRandom().nextBytes(this) } val spec = GCMParameterSpec(GCM_TAG_SIZE, iv) cipher.init(Cipher.ENCRYPT_MODE, key, spec) val encrypted = cipher.doFinal(data) From bc3ad8e5c5e53f5ce8a7edc22533151837091dc4 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 9 Jan 2025 12:02:32 +0530 Subject: [PATCH 5/7] PR changes --- .../service/location/ApiJourneyService.kt | 12 ++++---- .../data/service/location/JourneyKtx.kt | 28 +++++++++---------- .../data/service/user/ApiUserService.kt | 18 ++++++++---- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt index 196ac27b..32f70e64 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/ApiJourneyService.kt @@ -164,7 +164,7 @@ class ApiJourneyService @Inject constructor( val encryptedJourney = journey.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) - docRef.set(encryptedJourney).await() + encryptedJourney?.let { docRef.set(it).await() } } return journey } @@ -190,10 +190,12 @@ class ApiJourneyService @Inject constructor( val encryptedJourney = journey.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) try { - spaceMemberJourneyRef(spaceId, userId) - .document(journey.id) - .set(encryptedJourney) - .await() + encryptedJourney?.let { + spaceMemberJourneyRef(spaceId, userId) + .document(journey.id) + .set(it) + .await() + } } catch (e: Exception) { Timber.e(e, "Error updating journey") } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/location/JourneyKtx.kt b/data/src/main/java/com/canopas/yourspace/data/service/location/JourneyKtx.kt index b107a273..677999ec 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/location/JourneyKtx.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/location/JourneyKtx.kt @@ -12,16 +12,16 @@ import java.util.UUID /** * Convert an [EncryptedLocationJourney] to a [LocationJourney] using the provided [GroupCipher] */ -fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher): LocationJourney { - val decryptedFromLat = groupCipher.decrypt(from_latitude) - val decryptedFromLong = groupCipher.decrypt(from_longitude) +fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher): LocationJourney? { + val decryptedFromLat = groupCipher.decrypt(from_latitude) ?: return null + val decryptedFromLong = groupCipher.decrypt(from_longitude) ?: return null val decryptedToLat = to_latitude?.let { groupCipher.decrypt(it) } val decryptedToLong = to_longitude?.let { groupCipher.decrypt(it) } val decryptedRoutes = routes.map { JourneyRoute( - latitude = groupCipher.decrypt(it.latitude), - longitude = groupCipher.decrypt(it.longitude) + latitude = groupCipher.decrypt(it.latitude) ?: return null, + longitude = groupCipher.decrypt(it.longitude) ?: return null ) } @@ -48,16 +48,16 @@ fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher fun LocationJourney.toEncryptedLocationJourney( groupCipher: GroupCipher, distributionId: UUID -): EncryptedLocationJourney { - val encryptedFromLat = groupCipher.encrypt(distributionId, from_latitude) - val encryptedFromLong = groupCipher.encrypt(distributionId, from_longitude) +): EncryptedLocationJourney? { + val encryptedFromLat = groupCipher.encrypt(distributionId, from_latitude) ?: return null + val encryptedFromLong = groupCipher.encrypt(distributionId, from_longitude) ?: return null val encryptedToLat = to_latitude?.let { groupCipher.encrypt(distributionId, it) } val encryptedToLong = to_longitude?.let { groupCipher.encrypt(distributionId, it) } val encryptedRoutes = routes.map { EncryptedJourneyRoute( - latitude = groupCipher.encrypt(distributionId, it.latitude), - longitude = groupCipher.encrypt(distributionId, it.longitude) + latitude = groupCipher.encrypt(distributionId, it.latitude) ?: return null, + longitude = groupCipher.encrypt(distributionId, it.longitude) ?: return null ) } @@ -78,20 +78,20 @@ fun LocationJourney.toEncryptedLocationJourney( ) } -fun GroupCipher.decrypt(data: Blob): Double { +fun GroupCipher.decrypt(data: Blob): Double? { return try { decrypt(data.toBytes()).toString(Charsets.UTF_8).toDouble() } catch (e: Exception) { Timber.e(e, "Failed to decrypt double") - 0.0 + null } } -fun GroupCipher.encrypt(distributionId: UUID, data: Double): Blob { +fun GroupCipher.encrypt(distributionId: UUID, data: Double): Blob? { return try { Blob.fromBytes(encrypt(distributionId, data.toString().toByteArray(Charsets.UTF_8)).serialize()) } catch (e: Exception) { Timber.e(e, "Failed to encrypt double") - Blob.fromBytes(ByteArray(0)) + null } } diff --git a/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt b/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt index d87042c4..63ee2323 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt @@ -16,6 +16,7 @@ import com.google.firebase.auth.FirebaseUser import com.google.firebase.firestore.Blob import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore +import com.google.firebase.firestore.FirebaseFirestoreException import com.google.firebase.functions.FirebaseFunctions import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.map @@ -48,15 +49,20 @@ class ApiUserService @Inject constructor( suspend fun getUser(userId: String): ApiUser? { return try { - userRef.document(userId).get().await().toObject(ApiUser::class.java)?.let { user -> - if (currentUser == null || user.id != currentUser.id) return user - val decryptedPrivateKey = decryptPrivateKey(user) ?: return@let user - userPreferences.storePrivateKey(decryptedPrivateKey) - user + val user = userRef.document(userId).get().await().toObject(ApiUser::class.java) + when { + user == null -> null + currentUser == null || user.id != currentUser.id -> user + else -> decryptPrivateKey(user)?.let { decryptedKey -> + user.copy(identity_key_private = Blob.fromBytes(decryptedKey)) + } } - } catch (e: Exception) { + } catch (e: FirebaseFirestoreException) { Timber.e(e, "Error while getting user") null + } catch (e: SecurityException) { + Timber.e(e, "Error decrypting user data") + null } } From 659bfe22ad29e12fcc4f11b587978b7e491f6b1a Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 9 Jan 2025 12:13:17 +0530 Subject: [PATCH 6/7] minor change --- .../com/canopas/yourspace/data/service/user/ApiUserService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt b/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt index 63ee2323..3af443e8 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/user/ApiUserService.kt @@ -166,7 +166,7 @@ class ApiUserService @Inject constructor( return if (decryptedPrivateKey != null) { decryptedPrivateKey } else { - Timber.e("Failed to validate passkey for user ${user.id}") + Timber.e("Failed to validate passkey for user") null } } From 9c73d95993495e1b4321a7523a88c3dec05685d2 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 9 Jan 2025 15:02:54 +0530 Subject: [PATCH 7/7] minor changes --- .../storage/bufferedkeystore/BufferedSenderKeyStore.kt | 3 ++- .../bufferedkeystore/SignalServiceSenderKeyStore.kt | 9 --------- 2 files changed, 2 insertions(+), 10 deletions(-) delete mode 100644 data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt index 3ed29b5e..febd4117 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.tasks.await import org.signal.libsignal.protocol.SignalProtocolAddress import org.signal.libsignal.protocol.groups.state.SenderKeyRecord +import org.signal.libsignal.protocol.groups.state.SenderKeyStore import timber.log.Timber import java.util.UUID import javax.inject.Inject @@ -32,7 +33,7 @@ class BufferedSenderKeyStore @Inject constructor( @Named("sender_key_dao") private val senderKeyDao: SenderKeyDao, private val userPreferences: UserPreferences, private val appDispatcher: AppDispatcher -) : SignalServiceSenderKeyStore { +) : SenderKeyStore { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt deleted file mode 100644 index 771ad373..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.canopas.yourspace.data.storage.bufferedkeystore - -import org.signal.libsignal.protocol.groups.state.SenderKeyStore - -/** - * And extension of the normal protocol sender key store interface that has additional methods that are - * needed in the service layer, but not the protocol layer. - */ -interface SignalServiceSenderKeyStore : SenderKeyStore