From 7d0d68c63b05753ad9005a50e1a18660836eae0b Mon Sep 17 00:00:00 2001 From: cp-megh Date: Wed, 4 Dec 2024 15:35:01 +0530 Subject: [PATCH 01/30] WIP --- .../detail/UserJourneyDetailViewModel.kt | 2 +- .../service/location/ApiJourneyService.kt | 86 ++++++++++------- .../service/location/ApiLocationService.kt | 94 ++++++++----------- .../data/service/space/ApiSpaceService.kt | 2 +- 4 files changed, 93 insertions(+), 91 deletions(-) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt index 3e6c7d3d..1cd4eb53 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt @@ -58,7 +58,7 @@ class UserJourneyDetailViewModel @Inject constructor( private fun fetchJourney() = viewModelScope.launch(appDispatcher.IO) { try { _state.value = _state.value.copy(isLoading = true) - val journey = journeyService.getLocationJourneyFromId(userId, journeyId) + val journey = journeyService.getLocationJourneyFromId(journeyId) if (journey == null) { _state.value = _state.value.copy( isLoading = false, 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 3387fcc2..71050bc8 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,10 +2,14 @@ package com.canopas.yourspace.data.service.location import com.canopas.yourspace.data.models.location.JourneyRoute import com.canopas.yourspace.data.models.location.LocationJourney +import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.toObject +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.tasks.await import timber.log.Timber import javax.inject.Inject @@ -13,16 +17,24 @@ import javax.inject.Singleton @Singleton class ApiJourneyService @Inject constructor( - db: FirebaseFirestore + db: FirebaseFirestore, + private val userPreferences: UserPreferences ) { - private val userRef = db.collection(Config.FIRESTORE_COLLECTION_USERS) + var currentSpaceId: String = userPreferences.currentSpace ?: "" // App crashes sometimes because of the empty userId string passed to document(). // java.lang.IllegalArgumentException: Invalid document reference. // Document references must have an even number of segments, but users has 1 // https://stackoverflow.com/a/51195713/22508023 [Explanation can be found in comments] - private fun journeyRef(userId: String) = - userRef.document(userId.takeIf { it.isNotBlank() } ?: "null") + private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) + internal fun spaceMemberRef(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( + FIRESTORE_COLLECTION_SPACE_MEMBERS + ) + + private fun spaceMemberJourneyRef(spaceId: String) = + spaceMemberRef(spaceId) + .document(currentSpaceId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_JOURNEYS) suspend fun saveCurrentJourney( @@ -38,39 +50,43 @@ class ApiJourneyService @Inject constructor( updateAt: Long? = null, newJourneyId: ((String) -> Unit)? = null ) { - val docRef = journeyRef(userId).document() + userPreferences.currentUser?.space_ids?.forEach { + val docRef = spaceMemberJourneyRef(it).document() - val journey = LocationJourney( - id = docRef.id, - user_id = userId, - from_latitude = fromLatitude, - from_longitude = fromLongitude, - to_latitude = toLatitude, - to_longitude = toLongitude, - route_distance = routeDistance, - route_duration = routeDuration, - routes = routes, - created_at = createdAt ?: System.currentTimeMillis(), - update_at = updateAt ?: System.currentTimeMillis() - ) + val journey = LocationJourney( + id = docRef.id, + user_id = userId, + from_latitude = fromLatitude, + from_longitude = fromLongitude, + to_latitude = toLatitude, + to_longitude = toLongitude, + route_distance = routeDistance, + route_duration = routeDuration, + routes = routes, + created_at = createdAt ?: System.currentTimeMillis(), + update_at = updateAt ?: System.currentTimeMillis() + ) - newJourneyId?.invoke(journey.id) + newJourneyId?.invoke(journey.id) - docRef.set(journey).await() + docRef.set(journey).await() + } } suspend fun updateLastLocationJourney(userId: String, journey: LocationJourney) { try { - journeyRef(userId).document(journey.id).set(journey).await() + userPreferences.currentUser?.space_ids?.forEach { + spaceMemberJourneyRef(it).document(journey.id).set(journey).await() + } } catch (e: Exception) { Timber.e(e, "Error while updating last location journey") } } suspend fun getLastJourneyLocation(userId: String) = try { - journeyRef(userId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING).limit(1) - .get().await().documents.firstOrNull()?.toObject(LocationJourney::class.java) + .get().await().documents.firstOrNull()?.toObject() } catch (e: Exception) { Timber.e(e, "Error while getting last location journey") null @@ -80,17 +96,17 @@ class ApiJourneyService @Inject constructor( userId: String, from: Long? ): List { - if (from == null) { - return journeyRef(userId).whereEqualTo("user_id", userId) + val query = if (from == null) { + spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + .orderBy("created_at", Query.Direction.DESCENDING) + .limit(20) + } else { + spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) + .whereLessThan("created_at", from) .limit(20) - .get().await().documents.mapNotNull { it.toObject() } } - return journeyRef(userId).whereEqualTo("user_id", userId) - .orderBy("created_at", Query.Direction.DESCENDING) - .whereLessThan("created_at", from) - .limit(20) - .get().await().documents.mapNotNull { it.toObject() } + return query.get().await().documents.mapNotNull { it.toObject() } } suspend fun getJourneyHistory( @@ -98,13 +114,13 @@ class ApiJourneyService @Inject constructor( from: Long, to: Long ): List { - val previousDayJourney = journeyRef(userId).whereEqualTo("user_id", userId) + val previousDayJourney = spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .whereLessThan("created_at", from) .whereGreaterThanOrEqualTo("update_at", from) .limit(1) .get().await().documents.mapNotNull { it.toObject() } - val currentDayJourney = journeyRef(userId).whereEqualTo("user_id", userId) + val currentDayJourney = spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .whereGreaterThanOrEqualTo("created_at", from) .whereLessThanOrEqualTo("created_at", to) .orderBy("created_at", Query.Direction.DESCENDING) @@ -114,8 +130,8 @@ class ApiJourneyService @Inject constructor( return previousDayJourney + currentDayJourney } - suspend fun getLocationJourneyFromId(userId: String, journeyId: String): LocationJourney? { - return journeyRef(userId).document(journeyId).get().await() + suspend fun getLocationJourneyFromId(journeyId: String): LocationJourney? { + return spaceMemberJourneyRef(currentSpaceId).document(journeyId).get().await() .toObject(LocationJourney::class.java) } } 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 41999c99..1c8cd874 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 @@ -1,12 +1,15 @@ package com.canopas.yourspace.data.service.location +import android.util.Log import com.canopas.yourspace.data.models.location.ApiLocation +import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await import timber.log.Timber import javax.inject.Inject @@ -15,28 +18,38 @@ import javax.inject.Singleton @Singleton class ApiLocationService @Inject constructor( db: FirebaseFirestore, - private val locationManager: LocationManager + private val locationManager: LocationManager, + private val userPreferences: UserPreferences ) { - private val userRef = db.collection(Config.FIRESTORE_COLLECTION_USERS) - private fun locationRef(userId: String) = - userRef.document(userId.takeIf { it.isNotBlank() } ?: "null") + var currentSpaceId: String = userPreferences.currentSpace ?: "" + + private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) + private fun spaceMemberRef(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( + FIRESTORE_COLLECTION_SPACE_MEMBERS + ) + private fun spaceMemberLocationRef(spaceId: String) = + spaceMemberRef(spaceId) + .document(currentSpaceId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_LOCATIONS) suspend fun saveLastKnownLocation( userId: String ) { val lastLocation = locationManager.getLastLocation() ?: return - val docRef = locationRef(userId).document() + userPreferences.currentUser?.space_ids?.forEach { + val docRef = spaceMemberLocationRef(it).document() - val location = ApiLocation( - id = docRef.id, - user_id = userId, - latitude = lastLocation.latitude, - longitude = lastLocation.longitude, - created_at = System.currentTimeMillis() - ) + val location = ApiLocation( + id = docRef.id, + user_id = userId, + latitude = lastLocation.latitude, + longitude = lastLocation.longitude, + created_at = System.currentTimeMillis() + ) - docRef.set(location).await() + docRef.set(location).await() + } } suspend fun saveCurrentLocation( @@ -45,22 +58,25 @@ class ApiLocationService @Inject constructor( longitude: Double, recordedAt: Long ) { - val docRef = locationRef(userId).document() + Log.e("XXXXXX", "SpaceId: $currentSpaceId") + userPreferences.currentUser?.space_ids?.forEach { + val docRef = spaceMemberLocationRef(it).document() - val location = ApiLocation( - id = docRef.id, - user_id = userId, - latitude = latitude, - longitude = longitude, - created_at = recordedAt - ) + val location = ApiLocation( + id = docRef.id, + user_id = userId, + latitude = latitude, + longitude = longitude, + created_at = recordedAt + ) - docRef.set(location).await() + docRef.set(location).await() + } } fun getCurrentLocation(userId: String): Flow>? { return try { - locationRef(userId).whereEqualTo("user_id", userId) + spaceMemberLocationRef(currentSpaceId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING).limit(1) .snapshotFlow(ApiLocation::class.java) } catch (e: Exception) { @@ -68,34 +84,4 @@ class ApiLocationService @Inject constructor( null } } - - suspend fun getLastFiveMinuteLocations(userId: String): Flow> { - val currentTime = System.currentTimeMillis() - val locations = mutableListOf() - - for (i in 0 until 5) { - try { - val startTime = currentTime - (i + 1) * 60000 - val endTime = startTime - 60000 - val reference = locationRef(userId) - val apiLocation = reference - .whereEqualTo("user_id", userId) - .whereGreaterThanOrEqualTo("created_at", endTime) - .whereLessThan("created_at", startTime) - .orderBy("created_at", Query.Direction.DESCENDING).limit(1) - .get().await().documents - .firstOrNull()?.toObject(ApiLocation::class.java) - - apiLocation?.let { - locations.add(it) - } - } catch (e: Exception) { - Timber.e(e, "Error while getting last $i minute locations") - } - } - - return flow { - emit(locations) - } - } } 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 c20f7fc2..cb9cdf3d 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 @@ -23,7 +23,7 @@ class ApiSpaceService @Inject constructor( private val placeService: ApiPlaceService ) { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) - private fun spaceMemberRef(spaceId: String) = + internal fun spaceMemberRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) suspend fun createSpace(spaceName: String): String { From b3232a708478d24a0b101b4b31077caa0141277e Mon Sep 17 00:00:00 2001 From: cp-megh Date: Fri, 6 Dec 2024 14:57:21 +0530 Subject: [PATCH 02/30] WIP --- .../canopas/yourspace/YourSpaceApplication.kt | 2 +- build.gradle.kts | 1 + data/build.gradle.kts | 29 +++++++++++++++ data/proguard-rules.pro | 4 ++- .../data/models/location/ApiLocation.kt | 10 ++++++ .../data/models/location/LocationJourney.kt | 24 +++++++++++++ firestore.rules | 36 +++++++++++++++++-- 7 files changed, 101 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt index 280911f1..260071a3 100644 --- a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt +++ b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt @@ -53,7 +53,7 @@ class YourSpaceApplication : super.onCreate() Timber.plant(Timber.DebugTree()) - FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = !BuildConfig.DEBUG + FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = false ProcessLifecycleOwner.get().lifecycle.addObserver(this) authService.addListener(this) setNotificationChannel() diff --git a/build.gradle.kts b/build.gradle.kts index 6161aafc..f4d20266 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,4 +9,5 @@ plugins { id("com.google.firebase.crashlytics") version "3.0.2" apply false id("org.jetbrains.kotlin.jvm") version "1.9.23" id("com.google.devtools.ksp") version "1.9.23-1.0.20" + id("com.google.protobuf") version "0.9.4" apply false } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index e0d79d8e..f7203dc2 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("com.google.dagger.hilt.android") id("org.jlleitschuh.gradle.ktlint") id("com.google.devtools.ksp") + id("com.google.protobuf") } android { @@ -37,6 +38,9 @@ android { ktlint { debug = true } + configurations.all { + resolutionStrategy.force("com.google.protobuf:protobuf-javalite:3.10.0") + } } dependencies { @@ -83,4 +87,29 @@ dependencies { // Place implementation("com.google.android.libraries.places:places:4.0.0") + + // Signal Protocol + implementation("org.whispersystems:signal-protocol-android:2.8.1") { + exclude(group = "com.google.protobuf", module = "protolite-java") + } + implementation("com.google.protobuf:protobuf-javalite:3.10.0") // Align with Signal Protocol version + + // AndroidX Security for EncryptedSharedPreferences + implementation("androidx.security:security-crypto:1.1.0-alpha06") + +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.10.0" + } + generateProtoTasks { + all().forEach { task -> + task.builtins { + create("java") { + option("lite") + } + } + } + } } diff --git a/data/proguard-rules.pro b/data/proguard-rules.pro index 481bb434..4402fd6f 100644 --- a/data/proguard-rules.pro +++ b/data/proguard-rules.pro @@ -18,4 +18,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile +-keep class com.google.protobuf.** { *; } +-keep class org.whispersystems.** { *; } \ No newline at end of file diff --git a/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt b/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt index 274383f7..04d34497 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt @@ -14,6 +14,16 @@ data class ApiLocation( val created_at: Long? = System.currentTimeMillis() ) +@Keep +@JsonClass(generateAdapter = true) +data class EncryptedApiLocation( + val id: String = UUID.randomUUID().toString(), + val user_id: String = "", + val encrypted_latitude: String = "", // Base64 encoded encrypted latitude + val encrypted_longitude: String = "", // Base64 encoded encrypted longitude + val created_at: Long? = System.currentTimeMillis() +) + fun ApiLocation.toLocation() = android.location.Location("").apply { latitude = this@toLocation.latitude longitude = this@toLocation.longitude 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 d01a572c..b3b27907 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,9 +22,33 @@ data class LocationJourney( val update_at: Long? = System.currentTimeMillis() ) +@Keep +@JsonClass(generateAdapter = true) +data class EncryptedLocationJourney( + val id: String = UUID.randomUUID().toString(), + val user_id: String = "", + val encrypted_from_latitude: String = "", // Base64 encoded + val encrypted_from_longitude: String = "", + val encrypted_to_latitude: String? = "", + val encrypted_to_longitude: String? = "", + val route_distance: Double? = null, + val route_duration: Long? = null, + val encrypted_routes: List = emptyList(), + val created_at: Long? = System.currentTimeMillis(), + val updated_at: Long? = System.currentTimeMillis() +) + + @Keep data class JourneyRoute(val latitude: Double = 0.0, val longitude: Double = 0.0) +@Keep +@JsonClass(generateAdapter = true) +data class EncryptedJourneyRoute( + val encrypted_latitude: String = "", + val encrypted_longitude: String = "" +) + fun Location.toRoute(): JourneyRoute { return JourneyRoute(latitude, longitude) } diff --git a/firestore.rules b/firestore.rules index d9306cd5..4cb4670c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -32,7 +32,7 @@ service cloud.firestore { match /users/{docId} { allow create : if isAuthorized() && request.auth.uid == docId && request.resource.data.keys().hasAll(["id", "auth_type", "location_enabled", "provider_firebase_id_token", "created_at"]) && - request.resource.data.keys().hasAny(["email", "phone"]) && + request.resource.data.email is string && request.resource.data.id is string && request.resource.data.auth_type is int && (request.resource.data.auth_type == 1 || request.resource.data.auth_type == 2) && @@ -40,7 +40,6 @@ service cloud.firestore { request.resource.data.provider_firebase_id_token is string && request.resource.data.created_at is int && request.resource.data.get('first_name', '') is string && - request.resource.data.get('phone', '') is string && request.resource.data.get('email', '') is string && request.resource.data.get('last_name', '') is string && request.resource.data.get('fcm_token', '') is string && @@ -151,7 +150,7 @@ service cloud.firestore { request.resource.data.created_at is timestamp; } - function isPlaceAdmin(spaceId, placeId) { + function isPlaceAdmin(spaceId, place) { let created_by = get(/databases/$(database)/documents/spaces/$(spaceId)/space_places/$(place)).data.created_by; return request.auth.uid == created_by; } @@ -193,6 +192,37 @@ 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; + + function readSpaceMemberLocation(spaceId) { + return exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); + } + + match /user_locations/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readSpaceMemberLocation()); + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "sender_key", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.latitude is string && + request.resource.data.longitude is string && + request.resource.data.sender_key is string && + request.resource.data.created_at is int; + } + + match /user_journeys/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readSpaceMemberLocation()); + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.from_latitude is string && + request.resource.data.from_longitude is string && + request.resource.data.created_at is int; + } } match /space_invitations/{docId} { From 78bd44ad649e97780c2c330062afc65148b9b327 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 12 Dec 2024 18:26:20 +0530 Subject: [PATCH 03/30] WIP --- app/build.gradle.kts | 6 +- .../detail/UserJourneyDetailViewModel.kt | 2 +- build.gradle.kts | 1 - data/build.gradle.kts | 35 +- .../data/models/location/LocationJourney.kt | 1 - .../yourspace/data/models/space/ApiSpace.kt | 1 + .../yourspace/data/models/user/ApiUser.kt | 19 +- .../security/entity/BaseEncryptedEntity.kt | 8 + .../yourspace/data/security/helper/Helper.kt | 13 + .../data/security/helper/SignalKeyHelper.kt | 192 +++++++ .../security/session/EncryptedSpaceSession.kt | 110 ++++ .../data/service/auth/AuthService.kt | 2 +- .../service/location/ApiJourneyService.kt | 118 +++- .../service/location/ApiLocationService.kt | 190 ++++++- .../data/service/space/ApiSpaceService.kt | 61 +- .../data/service/user/ApiUserService.kt | 16 +- firestore.rules | 523 +++++++++--------- gradle.properties | 1 + 18 files changed, 941 insertions(+), 358 deletions(-) create mode 100644 data/src/main/java/com/canopas/yourspace/data/security/entity/BaseEncryptedEntity.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/security/helper/Helper.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/security/helper/SignalKeyHelper.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/security/session/EncryptedSpaceSession.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7aaae700..8f8fa696 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,12 +103,12 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } buildFeatures { compose = true diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt index 1cd4eb53..1177147e 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt @@ -58,7 +58,7 @@ class UserJourneyDetailViewModel @Inject constructor( private fun fetchJourney() = viewModelScope.launch(appDispatcher.IO) { try { _state.value = _state.value.copy(isLoading = true) - val journey = journeyService.getLocationJourneyFromId(journeyId) + val journey = journeyService.getLocationJourneyFromId(journeyId, userId) if (journey == null) { _state.value = _state.value.copy( isLoading = false, diff --git a/build.gradle.kts b/build.gradle.kts index f4d20266..6161aafc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,5 +9,4 @@ plugins { id("com.google.firebase.crashlytics") version "3.0.2" apply false id("org.jetbrains.kotlin.jvm") version "1.9.23" id("com.google.devtools.ksp") version "1.9.23-1.0.20" - id("com.google.protobuf") version "0.9.4" apply false } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index f7203dc2..8dde311c 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -4,7 +4,6 @@ plugins { id("com.google.dagger.hilt.android") id("org.jlleitschuh.gradle.ktlint") id("com.google.devtools.ksp") - id("com.google.protobuf") } android { @@ -28,19 +27,16 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } ktlint { debug = true } - configurations.all { - resolutionStrategy.force("com.google.protobuf:protobuf-javalite:3.10.0") - } } dependencies { @@ -89,27 +85,6 @@ dependencies { implementation("com.google.android.libraries.places:places:4.0.0") // Signal Protocol - implementation("org.whispersystems:signal-protocol-android:2.8.1") { - exclude(group = "com.google.protobuf", module = "protolite-java") - } - implementation("com.google.protobuf:protobuf-javalite:3.10.0") // Align with Signal Protocol version - - // AndroidX Security for EncryptedSharedPreferences - implementation("androidx.security:security-crypto:1.1.0-alpha06") - -} - -protobuf { - protoc { - artifact = "com.google.protobuf:protoc:3.10.0" - } - generateProtoTasks { - all().forEach { task -> - task.builtins { - create("java") { - option("lite") - } - } - } - } + implementation("org.signal:libsignal-client:0.64.1") + implementation("org.signal:libsignal-android:0.64.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 b3b27907..d2b9e617 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 @@ -38,7 +38,6 @@ data class EncryptedLocationJourney( val updated_at: Long? = System.currentTimeMillis() ) - @Keep data class JourneyRoute(val latitude: Double = 0.0, val longitude: Double = 0.0) 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 0d83f35b..692aabe2 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 @@ -10,6 +10,7 @@ data class ApiSpace( val id: String = UUID.randomUUID().toString(), val admin_id: String = "", val name: String = "", + val encryptedSenderKeys: Map> = emptyMap(), // User-specific encrypted keys val created_at: Long? = System.currentTimeMillis() ) diff --git a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt index e35e5e98..c00be17a 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt @@ -3,6 +3,9 @@ package com.canopas.yourspace.data.models.user import androidx.annotation.Keep import com.google.firebase.firestore.Exclude import com.squareup.moshi.JsonClass +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.state.PreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord import java.util.UUID const val LOGIN_TYPE_GOOGLE = 1 @@ -24,7 +27,12 @@ data class ApiUser( val state: Int = USER_STATE_UNKNOWN, val battery_pct: Float? = 0f, val created_at: Long? = System.currentTimeMillis(), - val updated_at: Long? = System.currentTimeMillis() + val updated_at: Long? = System.currentTimeMillis(), + val public_key: String? = null, // Identity public key (Base64-encoded) + val private_key: String? = null, // Identity private key (Base64-encoded and encrypted) + val pre_keys: List? = null, // List of serialized PreKeys (Base64-encoded) + val signed_pre_key: String? = null, // Serialized Signed PreKey (Base64-encoded) + val registration_id: Int = 0 // Signal Protocol registration ID ) { @get:Exclude val fullName: String get() = "$first_name $last_name" @@ -39,6 +47,15 @@ data class ApiUser( val locationPermissionDenied: Boolean get() = state == USER_STATE_LOCATION_PERMISSION_DENIED } +@Keep +@JsonClass(generateAdapter = false) +data class SignalKeys( + val identityKeyPair: IdentityKeyPair, + val signedPreKey: SignedPreKeyRecord, + val preKeys: List, + val registrationId: Int +) + @Keep @JsonClass(generateAdapter = true) data class ApiUserSession( diff --git a/data/src/main/java/com/canopas/yourspace/data/security/entity/BaseEncryptedEntity.kt b/data/src/main/java/com/canopas/yourspace/data/security/entity/BaseEncryptedEntity.kt new file mode 100644 index 00000000..5989dda5 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/security/entity/BaseEncryptedEntity.kt @@ -0,0 +1,8 @@ +package com.canopas.yourspace.data.security.entity + +import org.signal.libsignal.protocol.SignalProtocolAddress + +abstract class BaseEncryptedEntity protected constructor( + val registrationId: Int, + val signalProtocolAddress: SignalProtocolAddress +) diff --git a/data/src/main/java/com/canopas/yourspace/data/security/helper/Helper.kt b/data/src/main/java/com/canopas/yourspace/data/security/helper/Helper.kt new file mode 100644 index 00000000..2d257ebd --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/security/helper/Helper.kt @@ -0,0 +1,13 @@ +package com.canopas.yourspace.data.security.helper + +import android.util.Base64 + +object Helper { + fun encodeToBase64(value: ByteArray?): String { + return Base64.encodeToString(value, Base64.NO_WRAP) + } + + fun decodeToByteArray(base64: String?): ByteArray { + return Base64.decode(base64, Base64.NO_WRAP) + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/security/helper/SignalKeyHelper.kt b/data/src/main/java/com/canopas/yourspace/data/security/helper/SignalKeyHelper.kt new file mode 100644 index 00000000..02c2a8e8 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/security/helper/SignalKeyHelper.kt @@ -0,0 +1,192 @@ +package com.canopas.yourspace.data.security.helper + +import android.util.Base64 +import com.canopas.yourspace.data.models.user.ApiUser +import com.canopas.yourspace.data.models.user.SignalKeys +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.InvalidKeyException +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.protocol.groups.GroupSessionBuilder +import org.signal.libsignal.protocol.groups.state.SenderKeyRecord +import org.signal.libsignal.protocol.state.PreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.libsignal.protocol.state.impl.InMemorySignalProtocolStore +import org.signal.libsignal.protocol.util.KeyHelper +import org.signal.libsignal.protocol.util.Medium +import java.util.LinkedList +import java.util.Random +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.SecretKeySpec +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SignalKeyHelper @Inject constructor() { + + private fun generateIdentityKeyPair(): IdentityKeyPair { + val djbKeyPair = Curve.generateKeyPair() + val djbIdentityKey = IdentityKey(djbKeyPair.publicKey) + val djbPrivateKey = djbKeyPair.privateKey + + return IdentityKeyPair(djbIdentityKey, djbPrivateKey) + } + + @Throws(InvalidKeyException::class) + fun generateSignedPreKey( + identityKeyPair: IdentityKeyPair, + signedPreKeyId: Int + ): SignedPreKeyRecord { + val keyPair = Curve.generateKeyPair() + val signature = + Curve.calculateSignature(identityKeyPair.privateKey, keyPair.publicKey.serialize()) + return SignedPreKeyRecord(signedPreKeyId, System.currentTimeMillis(), keyPair, signature) + } + + private fun generatePreKeys(): List { + val records: MutableList = LinkedList() + val preKeyIdOffset = Random().nextInt(Medium.MAX_VALUE - 101) + for (i in 0 until 100) { + val preKeyId = (preKeyIdOffset + i) % Medium.MAX_VALUE + val keyPair = Curve.generateKeyPair() + val record = PreKeyRecord(preKeyId, keyPair) + + records.add(record) + } + + return records + } + + fun generateSignalKeys(): SignalKeys { + val identityKeyPair = generateIdentityKeyPair() + val signedPreKey = generateSignedPreKey(identityKeyPair, Random().nextInt(Medium.MAX_VALUE - 1)) + val preKeys = generatePreKeys() + val registrationId = KeyHelper.generateRegistrationId(false) + + return SignalKeys( + identityKeyPair = identityKeyPair, + signedPreKey = signedPreKey, + preKeys = preKeys, + registrationId = registrationId + ) + } + + fun createDistributionKey( + user: ApiUser, + deviceId: String, + spaceId: String + ): Pair { + val signalProtocolAddress = SignalProtocolAddress(user.id, deviceId.hashCode()) + val identityKeyPair = IdentityKeyPair( + IdentityKey(Curve.decodePoint(Base64.decode(user.public_key, Base64.DEFAULT), 0)), + Curve.decodePrivatePoint(Base64.decode(user.private_key, Base64.DEFAULT)) + ) + val signalProtocolStore = InMemorySignalProtocolStore(identityKeyPair, user.registration_id) + val signedPreKeyId = SignedPreKeyRecord(Helper.decodeToByteArray(user.signed_pre_key)).id + val preKeys = SignedPreKeyRecord(Helper.decodeToByteArray(user.signed_pre_key)) + signalProtocolStore.storeSignedPreKey(signedPreKeyId, preKeys) + + user.pre_keys?.let { preKeyRecords -> + val deserializedPreKeys = + preKeyRecords.map { PreKeyRecord(Helper.decodeToByteArray(it)) } + for (record in deserializedPreKeys) { + signalProtocolStore.storePreKey(record.id, record) + } + } + val validSpaceId = try { + UUID.fromString(spaceId) // Validate if it's a proper UUID string + } catch (e: IllegalArgumentException) { + UUID.randomUUID() // Fallback to a new valid UUID if parsing fails + } + signalProtocolStore.storeSenderKey( + signalProtocolAddress, + validSpaceId, + SenderKeyRecord(Helper.decodeToByteArray(user.signed_pre_key)) + ) + + val sessionBuilder = GroupSessionBuilder(signalProtocolStore) + val senderKeyDistributionMessage = + sessionBuilder.create(signalProtocolAddress, validSpaceId) + val senderKeyRecord = signalProtocolStore.loadSenderKey(signalProtocolAddress, validSpaceId) + + return Pair( + Helper.encodeToBase64(senderKeyDistributionMessage.serialize()), + Helper.encodeToBase64(senderKeyRecord.serialize()) + ) + } + + private fun encryptAESKeyWithECDH( + aesKey: SecretKey, + publicKey: String, + senderPrivateKey: String + ): String { + val ecPublicKey = Curve.decodePoint(Base64.decode(publicKey, Base64.DEFAULT), 0) + val ecPrivateKey = Curve.decodePrivatePoint(Base64.decode(senderPrivateKey, Base64.DEFAULT)) + val sharedSecret = Curve.calculateAgreement(ecPublicKey, ecPrivateKey) + val secretKeySpec = SecretKeySpec(sharedSecret, 0, 32, "AES") + val cipher = Cipher.getInstance("AES") + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec) + val encryptedAESKey = cipher.doFinal(aesKey.encoded) + + return Base64.encodeToString(encryptedAESKey, Base64.NO_WRAP) + } + + private fun decryptAESKeyWithECDH( + encryptedAESKey: String, + privateKey: String, + senderPublicKey: String + ): SecretKey { + val ecPublicKey = Curve.decodePoint(Base64.decode(senderPublicKey, Base64.DEFAULT), 0) + val ecPrivateKey = Curve.decodePrivatePoint(Base64.decode(privateKey, Base64.DEFAULT)) + val sharedSecret = Curve.calculateAgreement(ecPublicKey, ecPrivateKey) + val secretKeySpec = SecretKeySpec(sharedSecret, 0, 32, "AES") + val cipher = Cipher.getInstance("AES") + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec) + val decryptedAESKeyBytes = cipher.doFinal(Base64.decode(encryptedAESKey, Base64.DEFAULT)) + + return SecretKeySpec(decryptedAESKeyBytes, "AES") + } + + fun encryptSenderKeyForGroup( + senderKey: String, + senderPrivateKey: String, + recipients: List + ): Map> { + val encryptedKeys = mutableMapOf>() + val keyGen = KeyGenerator.getInstance("AES") + val aesKey: SecretKey = keyGen.generateKey() + val cipher = Cipher.getInstance("AES") + cipher.init(Cipher.ENCRYPT_MODE, aesKey) + val encryptedSenderKey = + Base64.encodeToString(cipher.doFinal(senderKey.toByteArray()), Base64.NO_WRAP) + recipients.forEach { recipient -> + recipient?.let { + val recipientPublicKey = recipient.public_key!! + val encryptedAESKey = + encryptAESKeyWithECDH(aesKey, recipientPublicKey, senderPrivateKey) + encryptedKeys[recipient.id] = Pair(encryptedSenderKey, encryptedAESKey) + } + } + + return encryptedKeys + } + + fun decryptSenderKey( + encryptedSenderKey: String, + encryptedAESKey: String, + recipientPrivateKey: String, + senderPublicKey: String + ): String { + val aesKey = decryptAESKeyWithECDH(encryptedAESKey, recipientPrivateKey, senderPublicKey) + val cipher = Cipher.getInstance("AES") + cipher.init(Cipher.DECRYPT_MODE, aesKey) + val decryptedSenderKeyBytes = + cipher.doFinal(Base64.decode(encryptedSenderKey, Base64.DEFAULT)) + + return String(decryptedSenderKeyBytes) + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/security/session/EncryptedSpaceSession.kt b/data/src/main/java/com/canopas/yourspace/data/security/session/EncryptedSpaceSession.kt new file mode 100644 index 00000000..27b9eb40 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/security/session/EncryptedSpaceSession.kt @@ -0,0 +1,110 @@ +package com.canopas.yourspace.data.security.session + +import android.util.Base64 +import androidx.annotation.Keep +import com.canopas.yourspace.data.models.user.ApiUser +import com.canopas.yourspace.data.security.helper.Helper +import com.squareup.moshi.JsonClass +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.NoSessionException +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.protocol.groups.GroupCipher +import org.signal.libsignal.protocol.groups.GroupSessionBuilder +import org.signal.libsignal.protocol.groups.state.SenderKeyRecord +import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage +import org.signal.libsignal.protocol.state.PreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.libsignal.protocol.state.impl.InMemorySignalProtocolStore +import java.nio.charset.StandardCharsets +import java.util.UUID + +class EncryptedSpaceSession( + currentUser: ApiUser, + keyRecord: String?, + spaceId: String +) { + private val spaceDistributionId: UUID = try { + UUID.fromString(spaceId) // Validate if it's a proper UUID string + } catch (e: IllegalArgumentException) { + UUID.randomUUID() // Fallback to a new valid UUID if parsing fails + } + private val signalProtocolAddress = SignalProtocolAddress(currentUser.id, 1) + private val identityKeyPair = IdentityKeyPair( + IdentityKey(Curve.decodePoint(Base64.decode(currentUser.public_key, Base64.DEFAULT), 0)), + Curve.decodePrivatePoint(Base64.decode(currentUser.private_key, Base64.DEFAULT)) + ) + + private val protocolStore = InMemorySignalProtocolStore(identityKeyPair, currentUser.registration_id) + private val encryptGroupCipher: GroupCipher + private val decryptCiphers = mutableMapOf() + private val distributionStore = mutableMapOf() + private val sessionBuilder = GroupSessionBuilder(protocolStore) + + init { + val signedPreKey = SignedPreKeyRecord(Helper.decodeToByteArray(currentUser.signed_pre_key)) + protocolStore.storeSignedPreKey(signedPreKey.id, signedPreKey) + + currentUser.pre_keys?.forEach { preKey -> + val record = PreKeyRecord(Helper.decodeToByteArray(preKey)) + protocolStore.storePreKey(record.id, record) + } + + if (keyRecord != null) { + protocolStore.storeSenderKey( + signalProtocolAddress, + spaceDistributionId, + SenderKeyRecord(Helper.decodeToByteArray(keyRecord)) + ) + } + + encryptGroupCipher = GroupCipher(protocolStore, signalProtocolAddress) + } + + val keyRecord: String + get() { + val record = protocolStore.loadSenderKey(signalProtocolAddress, spaceDistributionId) + return Helper.encodeToBase64(record.serialize()) + } + + fun createSession(members: List) { + members.forEach { member -> + val distributionKey = Helper.decodeToByteArray(member.keyDistributionMessage) + val keyMessage = SenderKeyDistributionMessage(distributionKey) + val address = SignalProtocolAddress(member.userId, 1) + + if (!decryptCiphers.containsKey(member.userId) || + distributionStore[member.userId] != member.keyDistributionMessage + ) { + distributionStore.remove(member.userId) + decryptCiphers.remove(member.userId) + + sessionBuilder.process(address, keyMessage) + distributionStore[member.userId] = member.keyDistributionMessage + decryptCiphers[member.userId] = GroupCipher(protocolStore, address) + } + } + } + + fun encryptMessage(message: String): String { + val encrypted = encryptGroupCipher.encrypt( + spaceDistributionId, + message.toByteArray(StandardCharsets.UTF_8) + ) + return Helper.encodeToBase64(encrypted.serialize()) + } + + fun decryptMessage(encryptedMessage: String?, userId: String): String { + val cipher = decryptCiphers[userId] ?: throw NoSessionException("No cipher for user $userId") + val decrypted = cipher.decrypt(Helper.decodeToByteArray(encryptedMessage)) + return String(decrypted, StandardCharsets.UTF_8) + } +} + +@Keep +@JsonClass(generateAdapter = false) +data class SpaceKeyDistribution( + val userId: String, + val keyDistributionMessage: String +) 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 311e74b5..0923b2e2 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 @@ -75,7 +75,7 @@ class AuthService @Inject constructor( userPreferences.currentUser = newUser } - private var currentUserSession: ApiUserSession? + var currentUserSession: ApiUserSession? get() { return userPreferences.currentUserSession } 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 71050bc8..32b59605 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 @@ -1,7 +1,16 @@ 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.canopas.yourspace.data.models.space.ApiSpace +import com.canopas.yourspace.data.models.space.ApiSpaceMember +import com.canopas.yourspace.data.models.user.ApiUser +import com.canopas.yourspace.data.security.helper.SignalKeyHelper +import com.canopas.yourspace.data.security.session.EncryptedSpaceSession +import com.canopas.yourspace.data.security.session.SpaceKeyDistribution +import com.canopas.yourspace.data.service.user.ApiUserService import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES @@ -9,7 +18,6 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBER import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.toObject -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.tasks.await import timber.log.Timber import javax.inject.Inject @@ -18,7 +26,9 @@ import javax.inject.Singleton @Singleton class ApiJourneyService @Inject constructor( db: FirebaseFirestore, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val signalKeyHelper: SignalKeyHelper, + private val apiUserService: ApiUserService ) { var currentSpaceId: String = userPreferences.currentSpace ?: "" @@ -27,14 +37,14 @@ class ApiJourneyService @Inject constructor( // Document references must have an even number of segments, but users has 1 // https://stackoverflow.com/a/51195713/22508023 [Explanation can be found in comments] private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) - internal fun spaceMemberRef(spaceId: String) = + private fun spaceMemberRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( FIRESTORE_COLLECTION_SPACE_MEMBERS ) - private fun spaceMemberJourneyRef(spaceId: String) = + private fun spaceMemberJourneyRef(spaceId: String, userId: String) = spaceMemberRef(spaceId) - .document(currentSpaceId.takeIf { it.isNotBlank() } ?: "null") + .document(userId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_JOURNEYS) suspend fun saveCurrentJourney( @@ -50,33 +60,93 @@ class ApiJourneyService @Inject constructor( updateAt: Long? = null, newJourneyId: ((String) -> Unit)? = null ) { - userPreferences.currentUser?.space_ids?.forEach { - val docRef = spaceMemberJourneyRef(it).document() + val user = userPreferences.currentUser ?: return + val userDeviceId = userPreferences.currentUserSession?.device_id ?: return + userPreferences.currentUser?.space_ids?.forEach { spaceId -> + val (_, senderKeyRecord) = signalKeyHelper.createDistributionKey( + user = user, + deviceId = userDeviceId, + spaceId = spaceId + ) + val spaceSession = EncryptedSpaceSession( + currentUser = user, + keyRecord = senderKeyRecord, + spaceId = spaceId + ) + val spaceMembers = spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) + val mSenderKeyDistributionModel = ArrayList().apply { + spaceMembers.forEach { member -> + val memberUser = apiUserService.getUser(member.user_id) ?: return + val decryptedSenderKey = getDecryptedSenderKey( + spaceId, + memberUser, + memberUser.public_key!! + ) + add( + SpaceKeyDistribution( + member.user_id, + decryptedSenderKey + ) + ) + } + } + spaceSession.createSession(mSenderKeyDistributionModel) + val encryptedFromLatitude = spaceSession.encryptMessage(fromLatitude.toString()) + val encryptedFromLongitude = spaceSession.encryptMessage(fromLongitude.toString()) + val encryptedToLatitude = toLatitude?.let { spaceSession.encryptMessage(it.toString()) } + val encryptedToLongitude = toLongitude?.let { spaceSession.encryptMessage(it.toString()) } + val encryptedJourneyRoutes = routes.map { + EncryptedJourneyRoute( + encrypted_latitude = spaceSession.encryptMessage(it.latitude.toString()), + encrypted_longitude = spaceSession.encryptMessage(it.longitude.toString()) + ) + } + + val docRef = spaceMemberJourneyRef(spaceId, userId).document() - val journey = LocationJourney( + val encryptedJourney = EncryptedLocationJourney( id = docRef.id, user_id = userId, - from_latitude = fromLatitude, - from_longitude = fromLongitude, - to_latitude = toLatitude, - to_longitude = toLongitude, + encrypted_from_latitude = encryptedFromLatitude, + encrypted_from_longitude = encryptedFromLongitude, + encrypted_to_latitude = encryptedToLatitude, + encrypted_to_longitude = encryptedToLongitude, route_distance = routeDistance, route_duration = routeDuration, - routes = routes, + encrypted_routes = encryptedJourneyRoutes, created_at = createdAt ?: System.currentTimeMillis(), - update_at = updateAt ?: System.currentTimeMillis() + updated_at = updateAt ?: System.currentTimeMillis() ) - newJourneyId?.invoke(journey.id) + newJourneyId?.invoke(encryptedJourney.id) - docRef.set(journey).await() + docRef.set(encryptedJourney).await() } } + private suspend fun getDecryptedSenderKey( + spaceId: String, + recipient: ApiUser, + senderPublicKey: String + ): String { + val space = spaceRef.document(spaceId).get().await().toObject(ApiSpace::class.java) + ?: throw Exception("Space not found") + + val encryptedKeys = space.encryptedSenderKeys[recipient.id] + ?: throw Exception("No keys found for recipient") + + return signalKeyHelper.decryptSenderKey( + encryptedSenderKey = encryptedKeys["encryptedSenderKey"]!!, + encryptedAESKey = encryptedKeys["encryptedAESKey"]!!, + recipientPrivateKey = recipient.private_key!!, + senderPublicKey = senderPublicKey + ) + } + suspend fun updateLastLocationJourney(userId: String, journey: LocationJourney) { try { userPreferences.currentUser?.space_ids?.forEach { - spaceMemberJourneyRef(it).document(journey.id).set(journey).await() + spaceMemberJourneyRef(it, userId).document(journey.id).set(journey).await() } } catch (e: Exception) { Timber.e(e, "Error while updating last location journey") @@ -84,7 +154,7 @@ class ApiJourneyService @Inject constructor( } suspend fun getLastJourneyLocation(userId: String) = try { - spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING).limit(1) .get().await().documents.firstOrNull()?.toObject() } catch (e: Exception) { @@ -97,11 +167,11 @@ class ApiJourneyService @Inject constructor( from: Long? ): List { val query = if (from == null) { - spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) .limit(20) } else { - spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) .whereLessThan("created_at", from) .limit(20) @@ -114,13 +184,13 @@ class ApiJourneyService @Inject constructor( from: Long, to: Long ): List { - val previousDayJourney = spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + val previousDayJourney = spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .whereLessThan("created_at", from) .whereGreaterThanOrEqualTo("update_at", from) .limit(1) .get().await().documents.mapNotNull { it.toObject() } - val currentDayJourney = spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + val currentDayJourney = spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .whereGreaterThanOrEqualTo("created_at", from) .whereLessThanOrEqualTo("created_at", to) .orderBy("created_at", Query.Direction.DESCENDING) @@ -130,8 +200,8 @@ class ApiJourneyService @Inject constructor( return previousDayJourney + currentDayJourney } - suspend fun getLocationJourneyFromId(journeyId: String): LocationJourney? { - return spaceMemberJourneyRef(currentSpaceId).document(journeyId).get().await() + suspend fun getLocationJourneyFromId(journeyId: String, userId: String): LocationJourney? { + return spaceMemberJourneyRef(currentSpaceId, userId = userId).document(journeyId).get().await() .toObject(LocationJourney::class.java) } } 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 1c8cd874..e7c76b30 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 @@ -1,15 +1,23 @@ package com.canopas.yourspace.data.service.location -import android.util.Log import com.canopas.yourspace.data.models.location.ApiLocation +import com.canopas.yourspace.data.models.location.EncryptedApiLocation +import com.canopas.yourspace.data.models.space.ApiSpace +import com.canopas.yourspace.data.models.space.ApiSpaceMember +import com.canopas.yourspace.data.models.user.ApiUser +import com.canopas.yourspace.data.security.helper.SignalKeyHelper +import com.canopas.yourspace.data.security.session.EncryptedSpaceSession +import com.canopas.yourspace.data.security.session.SpaceKeyDistribution import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_USERS import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await import timber.log.Timber import javax.inject.Inject @@ -19,36 +27,79 @@ import javax.inject.Singleton class ApiLocationService @Inject constructor( db: FirebaseFirestore, private val locationManager: LocationManager, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val signalKeyHelper: SignalKeyHelper ) { var currentSpaceId: String = userPreferences.currentSpace ?: "" private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) + private val userRef = db.collection(FIRESTORE_COLLECTION_USERS) private fun spaceMemberRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( FIRESTORE_COLLECTION_SPACE_MEMBERS ) - private fun spaceMemberLocationRef(spaceId: String) = + private fun spaceMemberLocationRef(spaceId: String, userId: String) = spaceMemberRef(spaceId) - .document(currentSpaceId.takeIf { it.isNotBlank() } ?: "null") + .document(userId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_LOCATIONS) suspend fun saveLastKnownLocation( userId: String ) { val lastLocation = locationManager.getLastLocation() ?: return - userPreferences.currentUser?.space_ids?.forEach { - val docRef = spaceMemberLocationRef(it).document() + val user = userPreferences.currentUser ?: return + val userDeviceId = userPreferences.currentUserSession?.device_id ?: return + userPreferences.currentUser?.space_ids?.forEach { spaceId -> + val (_, senderKeyRecord) = signalKeyHelper.createDistributionKey( + user = user, + deviceId = userDeviceId, + spaceId = spaceId + ) + val spaceSession = EncryptedSpaceSession( + currentUser = user, + keyRecord = senderKeyRecord, + spaceId = spaceId + ) + val spaceMembers = spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) + val mSenderKeyDistributionModel = ArrayList().apply { + spaceMembers.forEach { member -> + val memberUser = getUser(member.user_id) ?: return + val decryptedSenderKey = getDecryptedSenderKey( + spaceId, + memberUser, + memberUser.public_key!! + ) + add( + SpaceKeyDistribution( + member.user_id, + decryptedSenderKey + ) + ) + } + } + spaceSession.createSession(mSenderKeyDistributionModel) + val encryptedLatitude = spaceSession.encryptMessage(lastLocation.latitude.toString()) + val encryptedLongitude = spaceSession.encryptMessage(lastLocation.longitude.toString()) + val docRef = spaceMemberLocationRef(spaceId, userId).document() - val location = ApiLocation( + val encryptedLocation = EncryptedApiLocation( id = docRef.id, user_id = userId, - latitude = lastLocation.latitude, - longitude = lastLocation.longitude, + encrypted_latitude = encryptedLatitude, + encrypted_longitude = encryptedLongitude, created_at = System.currentTimeMillis() ) - docRef.set(location).await() + docRef.set(encryptedLocation).await() + } + } + + suspend fun getUser(userId: String): ApiUser? { + return try { + userRef.document(userId).get().await().toObject(ApiUser::class.java) + } catch (e: Exception) { + Timber.e(e, "Error while getting user") + null } } @@ -58,30 +109,119 @@ class ApiLocationService @Inject constructor( longitude: Double, recordedAt: Long ) { - Log.e("XXXXXX", "SpaceId: $currentSpaceId") - userPreferences.currentUser?.space_ids?.forEach { - val docRef = spaceMemberLocationRef(it).document() + val user = userPreferences.currentUser ?: return + val userDeviceId = userPreferences.currentUserSession?.device_id ?: return + userPreferences.currentUser?.space_ids?.forEach { spaceId -> + val (_, senderKeyRecord) = signalKeyHelper.createDistributionKey( + user = user, + deviceId = userDeviceId, + spaceId = spaceId + ) + val spaceSession = EncryptedSpaceSession( + currentUser = user, + keyRecord = senderKeyRecord, + spaceId = spaceId + ) + val spaceMembers = spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) + val mSenderKeyDistributionModel = ArrayList().apply { + spaceMembers.forEach { member -> + val memberUser = getUser(member.user_id) ?: return + val decryptedSenderKey = getDecryptedSenderKey( + spaceId, + memberUser, + memberUser.public_key!! + ) + add( + SpaceKeyDistribution( + member.user_id, + decryptedSenderKey + ) + ) + } + } + spaceSession.createSession(mSenderKeyDistributionModel) + val encryptedLatitude = spaceSession.encryptMessage(latitude.toString()) + val encryptedLongitude = spaceSession.encryptMessage(longitude.toString()) + val docRef = spaceMemberLocationRef(spaceId, userId).document() - val location = ApiLocation( + val encryptedLocation = EncryptedApiLocation( id = docRef.id, user_id = userId, - latitude = latitude, - longitude = longitude, + encrypted_latitude = encryptedLatitude, + encrypted_longitude = encryptedLongitude, created_at = recordedAt ) - docRef.set(location).await() + docRef.set(encryptedLocation).await() } } - fun getCurrentLocation(userId: String): Flow>? { - return try { - spaceMemberLocationRef(currentSpaceId).whereEqualTo("user_id", userId) - .orderBy("created_at", Query.Direction.DESCENDING).limit(1) - .snapshotFlow(ApiLocation::class.java) - } catch (e: Exception) { - Timber.e(e, "Error while getting current location") - null + private suspend fun getDecryptedSenderKey( + spaceId: String, + recipient: ApiUser, + senderPublicKey: String + ): String { + val space = spaceRef.document(spaceId).get().await().toObject(ApiSpace::class.java) + ?: throw Exception("Space not found") + + val encryptedKeys = space.encryptedSenderKeys[recipient.id] + ?: throw Exception("No keys found for recipient") + + return signalKeyHelper.decryptSenderKey( + encryptedSenderKey = encryptedKeys["encryptedSenderKey"]!!, + encryptedAESKey = encryptedKeys["encryptedAESKey"]!!, + recipientPrivateKey = recipient.private_key!!, + senderPublicKey = senderPublicKey + ) + } + + fun getCurrentLocation(userId: String): Flow> { + return flow { + try { + val encryptedLocation = spaceMemberLocationRef(currentSpaceId, userId) + .whereEqualTo("user_id", userId) + .orderBy("created_at", Query.Direction.DESCENDING) + .limit(1) + .snapshotFlow(EncryptedApiLocation::class.java) + + encryptedLocation.collect { encryptedLocationList -> + val apiLocations = encryptedLocationList.mapNotNull { encryptedLocation -> + val user = getUser(encryptedLocation.user_id) ?: return@mapNotNull null + val senderPublicKey = user.public_key ?: return@mapNotNull null + val space = spaceRef.document(currentSpaceId).get().await().toObject(ApiSpace::class.java) + ?: throw Exception("Space not found") + + val encryptedKeys = space.encryptedSenderKeys[user.id] + ?: throw Exception("No keys found for recipient") + + val decryptedSenderKey = signalKeyHelper.decryptSenderKey( + encryptedSenderKey = encryptedKeys["encryptedSenderKey"]!!, + encryptedAESKey = encryptedKeys["encryptedAESKey"]!!, + recipientPrivateKey = user.private_key!!, + senderPublicKey = senderPublicKey + ) + + val spaceSession = EncryptedSpaceSession( + currentUser = user, + keyRecord = decryptedSenderKey, + spaceId = currentSpaceId + ) + val decryptedLatitude = spaceSession.decryptMessage(encryptedLocation.encrypted_latitude, user.id) + val decryptedLongitude = spaceSession.decryptMessage(encryptedLocation.encrypted_longitude, user.id) + + ApiLocation( + user_id = user.id, + latitude = decryptedLatitude.toDouble(), + longitude = decryptedLongitude.toDouble() + ) + } + + emit(apiLocations) // Emit the list of ApiLocation + } + } catch (e: Exception) { + Timber.e(e, "Error while getting current location") + emit(emptyList()) // Emit an empty list in case of an error + } } } } 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 cb9cdf3d..ab0f19ce 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 @@ -4,6 +4,8 @@ import com.canopas.yourspace.data.models.space.ApiSpace import com.canopas.yourspace.data.models.space.ApiSpaceMember import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_ADMIN import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_MEMBER +import com.canopas.yourspace.data.models.user.ApiUser +import com.canopas.yourspace.data.security.helper.SignalKeyHelper import com.canopas.yourspace.data.service.auth.AuthService import com.canopas.yourspace.data.service.place.ApiPlaceService import com.canopas.yourspace.data.service.user.ApiUserService @@ -11,6 +13,8 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton @@ -20,10 +24,11 @@ class ApiSpaceService @Inject constructor( private val db: FirebaseFirestore, private val authService: AuthService, private val apiUserService: ApiUserService, - private val placeService: ApiPlaceService + private val placeService: ApiPlaceService, + private val signalKeyHelper: SignalKeyHelper ) { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) - internal fun spaceMemberRef(spaceId: String) = + private fun spaceMemberRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) suspend fun createSpace(spaceName: String): String { @@ -49,9 +54,45 @@ class ApiSpaceService @Inject constructor( it.set(member).await() } + val user = authService.currentUser ?: return + val userDeviceId = authService.currentUserSession?.device_id ?: return + val (senderKey, _) = signalKeyHelper.createDistributionKey( + user = user, + deviceId = userDeviceId, + spaceId = spaceId + ) + val spaceMembers = getMemberBySpaceId(spaceId).firstOrNull()?.map { + apiUserService.getUser(it.user_id) ?: return@map null + } + distributeSenderKeyToGroup( + spaceId = spaceId, + senderKey = senderKey, + senderPrivateKey = user.private_key!!, + members = spaceMembers ?: emptyList() + ) apiUserService.addSpaceId(userId, spaceId) } + private suspend fun distributeSenderKeyToGroup( + spaceId: String, + senderKey: String, + senderPrivateKey: String, + members: List + ) { + // Encrypt the Sender Key for each recipient + val encryptedKeys = signalKeyHelper.encryptSenderKeyForGroup(senderKey, senderPrivateKey, members) + val encryptedSenderKeysMap = encryptedKeys.mapValues { entry -> + mapOf( + "encryptedSenderKey" to entry.value.first, + "encryptedAESKey" to entry.value.second + ) + } + + spaceRef.document(spaceId) + .update("encryptedSenderKeys", encryptedSenderKeysMap) + .await() + } + suspend fun enableLocation(spaceId: String, userId: String, enable: Boolean) { spaceMemberRef(spaceId) .whereEqualTo("user_id", userId).get() @@ -95,6 +136,22 @@ class ApiSpaceService @Inject constructor( .whereEqualTo("user_id", userId).get().await().documents.forEach { it.reference.delete().await() } + val user = authService.currentUser ?: return + val userDeviceId = authService.currentUserSession?.device_id ?: return + val (senderKey, _) = signalKeyHelper.createDistributionKey( + user = user, + deviceId = userDeviceId, + spaceId = spaceId + ) + val spaceMembers = getMemberBySpaceId(spaceId).firstOrNull()?.map { + apiUserService.getUser(it.user_id) ?: return@map null + } + distributeSenderKeyToGroup( + spaceId = spaceId, + senderKey = senderKey, + senderPrivateKey = user.private_key!!, + members = spaceMembers ?: emptyList() + ) } 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 9f52f4ab..032eb8fb 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 @@ -4,6 +4,8 @@ import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.models.user.ApiUserSession import com.canopas.yourspace.data.models.user.LOGIN_TYPE_APPLE import com.canopas.yourspace.data.models.user.LOGIN_TYPE_GOOGLE +import com.canopas.yourspace.data.security.helper.Helper +import com.canopas.yourspace.data.security.helper.SignalKeyHelper import com.canopas.yourspace.data.service.location.ApiLocationService import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_USERS @@ -29,6 +31,7 @@ class ApiUserService @Inject constructor( db: FirebaseFirestore, private val device: Device, private val locationService: ApiLocationService, + private val signalKeyHelper: SignalKeyHelper, private val functions: FirebaseFunctions ) { private val userRef = db.collection(FIRESTORE_COLLECTION_USERS) @@ -74,6 +77,12 @@ class ApiUserService @Inject constructor( sessionDocRef.set(session).await() return Triple(false, savedUser, session) } else { + val signalKeys = signalKeyHelper.generateSignalKeys() + val publicKey = Helper.encodeToBase64(signalKeys.identityKeyPair.publicKey.serialize()) + val privateKey = Helper.encodeToBase64(signalKeys.identityKeyPair.privateKey.serialize()) + val preKeys = signalKeys.preKeys.map { Helper.encodeToBase64(it.serialize()) } + val signedPreKey = Helper.encodeToBase64(signalKeys.signedPreKey.serialize()) + val user = ApiUser( id = uid!!, email = account?.email ?: firebaseUser?.email ?: "", @@ -82,7 +91,12 @@ class ApiUserService @Inject constructor( last_name = account?.familyName ?: "", provider_firebase_id_token = firebaseToken, profile_image = account?.photoUrl?.toString() ?: firebaseUser?.photoUrl?.toString() - ?: "" + ?: "", + public_key = publicKey, + private_key = privateKey, + pre_keys = preKeys, + signed_pre_key = signedPreKey, + registration_id = signalKeys.registrationId ) userRef.document(uid).set(user).await() val sessionDocRef = sessionRef(user.id).document() diff --git a/firestore.rules b/firestore.rules index 4cb4670c..fb0c0c18 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,293 +1,280 @@ rules_version = '2'; service cloud.firestore { - match /databases/{database}/documents { + match /databases/{database}/documents { - function isAuthorized() { - return request.auth != null; - } - - function readUserLocation() { - let requestedUserSpaceIds = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.space_ids; - let resourceUserSpaceIds = get(/databases/$(database)/documents/users/$(resource.data.user_id)).data.space_ids; - return requestedUserSpaceIds.hasAny(resourceUserSpaceIds); - } - - match /support_requests/{docId} { - allow create : if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["user_id", "title", "description", "device_name","app_version","device_os", "created_at"]) && - request.resource.data.user_id is string && - request.resource.data.title is string && - request.resource.data.description is string && - request.resource.data.device_name is string && - request.resource.data.app_version is string && - request.resource.data.device_os is string && - request.resource.data.created_at is timestamp && - request.resource.data.get('attachments', []) is list; - allow update: if false; - allow delete: if false; - allow read: if isAuthorized() && request.auth.uid == resource.data.user_id; - } - - match /users/{docId} { - allow create : if isAuthorized() && request.auth.uid == docId && - request.resource.data.keys().hasAll(["id", "auth_type", "location_enabled", "provider_firebase_id_token", "created_at"]) && - request.resource.data.email is string && - request.resource.data.id is string && - request.resource.data.auth_type is int && - (request.resource.data.auth_type == 1 || request.resource.data.auth_type == 2) && - request.resource.data.location_enabled is bool && - request.resource.data.provider_firebase_id_token is string && - request.resource.data.created_at is int && - request.resource.data.get('first_name', '') is string && - request.resource.data.get('email', '') is string && - request.resource.data.get('last_name', '') is string && - request.resource.data.get('fcm_token', '') is string && - request.resource.data.get('profile_image', '') is string && - request.resource.data.get('space_ids', []) is list; - - allow update: if isAuthorized() && request.auth.uid == resource.data.id && - request.resource.data.diff(resource.data).affectedKeys().hasAny(['first_name', 'last_name', 'profile_image', 'location_enabled', 'space_ids', 'phone', 'email','fcm_token', 'updated_at', 'battery_pct', 'state']) && - request.resource.data.first_name is string && - request.resource.data.get('last_name', '') is string && - request.resource.data.get('fcm_token', '') is string && - request.resource.data.location_enabled is bool && - request.resource.data.get('space_ids', []) is list; - - allow delete: if isAuthorized() && request.auth.uid == resource.data.id; - allow read: if isAuthorized(); - - match /user_locations/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.latitude is number && - request.resource.data.longitude is number && - request.resource.data.created_at is int; - } - - match /user_journeys/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.from_latitude is number && - request.resource.data.from_longitude is number && - request.resource.data.created_at is int; - } - - - match /user_sessions/{docId} { - allow read: if isAuthorized(); - allow create : if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "device_id", "device_name", "platform", "session_active", "app_version", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.device_id is string && - request.resource.data.device_name is string && - request.resource.data.platform is int && - request.resource.data.platform == 1 && - request.resource.data.session_active is bool && - request.resource.data.app_version is int && - request.resource.data.created_at is int; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - } - } - - match /users/{docId}/user_sessions/{document=**} { - allow read: if isAuthorized() && request.auth.uid == docId; - } - - function isSpaceAdmin(spaceId) { - let adminId = get(/databases/$(database)/documents/spaces/$(spaceId)).data.admin_id; - return request.auth.uid == adminId; - } + function isAuthorized() { + return request.auth != null; + } - function isSpaceMember(spaceId) { - let isMember = exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); - return isMember; - } + function readUserLocation() { + let requestedUserSpaceIds = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.space_ids; + let resourceUserSpaceIds = get(/databases/$(database)/documents/users/$(resource.data.user_id)).data.space_ids; + return requestedUserSpaceIds.hasAny(resourceUserSpaceIds); + } - match /spaces/{docId} { - allow read: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.admin_id; - allow update: if isAuthorized() && request.auth.uid == resource.data.admin_id; - allow create: if isAuthorized() && - request.resource.data.keys().hasAll(["id", "admin_id", "name", "created_at"]) && - request.resource.data.id is string && - request.resource.data.admin_id is string && - request.resource.data.name is string && - request.resource.data.created_at is int; + match /support_requests/{docId} { + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["user_id", "title", "description", "device_name", "app_version", "device_os", "created_at"]) && + request.resource.data.user_id is string && + request.resource.data.title is string && + request.resource.data.description is string && + request.resource.data.device_name is string && + request.resource.data.app_version is string && + request.resource.data.device_os is string && + request.resource.data.created_at is timestamp && + request.resource.data.get('attachments', []) is list; + allow update: if false; + allow delete: if false; + allow read: if isAuthorized() && request.auth.uid == resource.data.user_id; + } + match /users/{docId} { + allow create: if isAuthorized() && request.auth.uid == docId && + request.resource.data.keys().hasAll(["id", "auth_type", "location_enabled", "provider_firebase_id_token", "created_at"]) && + request.resource.data.email is string && + request.resource.data.id is string && + request.resource.data.auth_type is int && + (request.resource.data.auth_type == 1 || request.resource.data.auth_type == 2) && + request.resource.data.location_enabled is bool && + request.resource.data.provider_firebase_id_token is string && + request.resource.data.created_at is int && + request.resource.data.get('first_name', '') is string && + request.resource.data.get('email', '') is string && + request.resource.data.get('last_name', '') is string && + request.resource.data.get('fcm_token', '') is string && + request.resource.data.get('profile_image', '') is string && + request.resource.data.get('space_ids', []) is list; + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.id; + allow read: if isAuthorized(); + + match /user_locations/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.latitude is number && + request.resource.data.longitude is number && + request.resource.data.created_at is int; + } + + match /user_journeys/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.from_latitude is number && + request.resource.data.from_longitude is number && + request.resource.data.created_at is int; + } + + match /user_sessions/{docId} { + allow read: if isAuthorized(); + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "device_id", "device_name", "platform", "session_active", "app_version", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.device_id is string && + request.resource.data.device_name is string && + request.resource.data.platform is int && + request.resource.data.platform == 1 && + request.resource.data.session_active is bool && + request.resource.data.app_version is int && + request.resource.data.created_at is int; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + } + } - } - match /{path=**}/space_places/{place} { - allow read: if isAuthorized() && isSpaceMember(resource.data.space_id); - allow write: if false; - } + match /users/{docId}/user_sessions/{document=**} { + allow read: if isAuthorized() && request.auth.uid == docId; + } - match /spaces/{spaceId}/space_places/{place} { - allow read: if isAuthorized() && isSpaceMember(spaceId); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.created_by); - allow update: if isAuthorized() && request.auth.uid == resource.data.created_by; - allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.created_by) && - request.resource.data.keys().hasAll(["id", "space_id", "created_by", "latitude", "longitude", "radius", "name", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.created_by is string && - request.resource.data.latitude is number && - request.resource.data.longitude is number && - request.resource.data.radius is number && - request.resource.data.name is string && - request.resource.data.created_at is timestamp; - } + function isSpaceAdmin(spaceId) { + let adminId = get(/databases/$(database)/documents/spaces/$(spaceId)).data.admin_id; + return request.auth.uid == adminId; + } - function isPlaceAdmin(spaceId, place) { - let created_by = get(/databases/$(database)/documents/spaces/$(spaceId)/space_places/$(place)).data.created_by; - return request.auth.uid == created_by; - } + function isSpaceMember(spaceId) { + let isMember = exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); + return isMember; + } - match /spaces/{spaceId}/space_places/{place}/place_settings_by_members/{member} { - allow read: if isAuthorized() && isSpaceMember(spaceId); - allow update: if isAuthorized() && isSpaceMember(spaceId) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["alert_enable", "leave_alert_for", "arrival_alert_for"]) && - request.resource.data.arrival_alert_for is list && - request.resource.data.leave_alert_for is list && - request.resource.data.alert_enable is bool; - allow delete: if isAuthorized() && (request.auth.uid == resource.data.user_id || isPlaceAdmin(place)); - allow create: if isAuthorized() && isSpaceMember(spaceId) && - request.resource.data.keys().hasAll(["user_id", "place_id", "alert_enable", "leave_alert_for", "arrival_alert_for"]) && - request.resource.data.user_id is string && - request.resource.data.place_id is string && - request.resource.data.get('arrival_alert_for', []) is list && - request.resource.data.get('leave_alert_for', []) is list && - request.resource.data.alert_enable is bool; - } + match /spaces/{docId} { + allow read: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.admin_id; + allow update: if isAuthorized() && request.auth.uid == resource.data.admin_id; + allow create: if isAuthorized() && + request.resource.data.keys().hasAll(["id", "admin_id", "name", "created_at"]) && + request.resource.data.id is string && + request.resource.data.admin_id is string && + request.resource.data.name is string && + request.resource.data.created_at is int; + } - match /{path=**}/space_members/{member} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(resource.data.space_id)); - allow write: if false; - } + match /{path=**}/space_places/{place} { + allow read: if isAuthorized() && isSpaceMember(resource.data.space_id); + allow write: if false; + } - match /spaces/{spaceId}/space_members/{member} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.user_id); - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["location_enabled"]) && - request.resource.data.location_enabled is bool; - allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.user_id) && - request.resource.data.keys().hasAll(["id", "space_id", "user_id", "role", "location_enabled", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.user_id is string && - request.resource.data.role is int && - (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_places/{place} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.created_by); + allow update: if isAuthorized() && request.auth.uid == resource.data.created_by; + allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.created_by) && + request.resource.data.keys().hasAll(["id", "space_id", "created_by", "latitude", "longitude", "radius", "name", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.created_by is string && + request.resource.data.latitude is number && + request.resource.data.longitude is number && + request.resource.data.radius is number && + request.resource.data.name is string && + request.resource.data.created_at is timestamp; + } - function readSpaceMemberLocation(spaceId) { - return exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); - } + function isPlaceAdmin(spaceId, place) { + let created_by = get(/databases/$(database)/documents/spaces/$(spaceId)/space_places/$(place)).data.created_by; + return request.auth.uid == created_by; + } - match /user_locations/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readSpaceMemberLocation()); - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "sender_key", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.latitude is string && - request.resource.data.longitude is string && - request.resource.data.sender_key is string && - request.resource.data.created_at is int; - } + match /spaces/{spaceId}/space_places/{place}/place_settings_by_members/{member} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + allow update: if isAuthorized() && isSpaceMember(spaceId) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["alert_enable", "leave_alert_for", "arrival_alert_for"]) && + request.resource.data.arrival_alert_for is list && + request.resource.data.leave_alert_for is list && + request.resource.data.alert_enable is bool; + allow delete: if isAuthorized() && (request.auth.uid == resource.data.user_id || isPlaceAdmin(spaceId, place)); + allow create: if isAuthorized() && isSpaceMember(spaceId) && + request.resource.data.keys().hasAll(["user_id", "place_id", "alert_enable", "leave_alert_for", "arrival_alert_for"]) && + request.resource.data.user_id is string && + request.resource.data.place_id is string && + request.resource.data.get('arrival_alert_for', []) is list && + request.resource.data.get('leave_alert_for', []) is list && + request.resource.data.alert_enable is bool; + } - match /user_journeys/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readSpaceMemberLocation()); - allow update: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.from_latitude is string && - request.resource.data.from_longitude is string && - request.resource.data.created_at is int; - } - } + match /{path=**}/space_members/{member} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(resource.data.space_id)); + allow write: if false; + } - match /space_invitations/{docId} { - allow read: if isAuthorized(); - allow delete: if isAuthorized() && isSpaceAdmin(resource.data.space_id); - allow update: if isAuthorized() && isSpaceMember(resource.data.space_id) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["code", "created_at"]) && - request.resource.data.code is string && - request.resource.data.code.size() == 6 && - request.resource.data.created_at is int; + match /spaces/{spaceId}/space_members/{member} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); + allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.user_id); + allow update: if isAuthorized() && + (request.auth.uid == resource.data.user_id || isSpaceAdmin(resource.data.space_id)) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["location_enabled", "role"]) && + ((request.resource.data.location_enabled is bool) || + (request.resource.data.role is int && (request.resource.data.role == 1 || request.resource.data.role == 2))); + allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.user_id) && + request.resource.data.keys().hasAll(["id", "space_id", "user_id", "role", "location_enabled", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.user_id is string && + request.resource.data.role is int && + (request.resource.data.role == 1 || request.resource.data.role == 2) && + request.resource.data.location_enabled is bool && + request.resource.data.created_at is int; + } - allow create: if isAuthorized() && isSpaceAdmin(request.resource.data.space_id) && - request.resource.data.keys().hasAll(["id", "code", "space_id", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.code is string && - request.resource.data.code.size() == 6 && - request.resource.data.created_at is int; - } + match /spaces/{spaceId}/space_members/{docId} { + match /user_locations/{docId} { + allow read: if true; + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "encrypted_latitude", "encrypted_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.encrypted_latitude is string && + request.resource.data.encrypted_longitude is string && + request.resource.data.created_at is int; + } + + match /user_journeys/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "encrypted_from_latitude", "encrypted_from_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.encrypted_from_latitude is string && + request.resource.data.encrypted_from_longitude is string && + request.resource.data.created_at is int; + } + } + match /space_invitations/{docId} { + allow read: if isAuthorized(); + allow delete: if isAuthorized() && isSpaceAdmin(resource.data.space_id); + allow update: if isAuthorized() && isSpaceMember(resource.data.space_id) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["code", "created_at"]) && + request.resource.data.code is string && + request.resource.data.code.size() == 6 && + request.resource.data.created_at is int; + allow create: if isAuthorized() && isSpaceAdmin(request.resource.data.space_id) && + request.resource.data.keys().hasAll(["id", "code", "space_id", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.code is string && + request.resource.data.code.size() == 6 && + request.resource.data.created_at is int; + } - match /space_threads/{docId} { - allow read: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || isSpaceMember(resource.data.space_id)); - allow delete: if isAuthorized() && isThreadAdmin(docId); - allow update: if isAuthorized() && isThreadMember(docId) && - request.resource.data.diff(resource.data).affectedKeys().hasAny(["member_ids", "archived_for"]) && - request.resource.data.get('member_ids', []) is list && - request.resource.data.get('archived_for', {}) is map; - allow create: if isAuthorized() && (isSpaceMember(request.resource.data.space_id)) && - request.resource.data.keys().hasAll(["id", "space_id", "admin_id", "member_ids", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.admin_id is string && - request.resource.data.member_ids is list && - request.resource.data.created_at is int; + match /space_threads/{docId} { + allow read: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || isSpaceMember(resource.data.space_id)); + allow delete: if isAuthorized() && isThreadAdmin(docId); + allow update: if isAuthorized() && isThreadMember(docId) && + request.resource.data.diff(resource.data).affectedKeys().hasAny(["member_ids", "archived_for"]) && + request.resource.data.get('member_ids', []) is list && + request.resource.data.get('archived_for', {}) is map; + allow create: if isAuthorized() && (isSpaceMember(request.resource.data.space_id)) && + request.resource.data.keys().hasAll(["id", "space_id", "admin_id", "member_ids", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.admin_id is string && + request.resource.data.member_ids is list && + request.resource.data.created_at is int; + } - } - function isThreadMember(threadId) { - let memberIds = get(/databases/$(database)/documents/space_threads/$(threadId)).data.member_ids; - return memberIds.hasAny([request.auth.uid]); - } + function isThreadMember(threadId) { + let memberIds = get(/databases/$(database)/documents/space_threads/$(threadId)).data.member_ids; + return memberIds.hasAny([request.auth.uid]); + } - function isThreadAdmin(threadId) { - let adminId = get(/databases/$(database)/documents/space_threads/$(threadId)).data.admin_id; - return adminId == request.auth.uid; - } + function isThreadAdmin(threadId) { + let adminId = get(/databases/$(database)/documents/space_threads/$(threadId)).data.admin_id; + return adminId == request.auth.uid; + } - match /{path=**}/thread_messages/{docId} { - allow read: if isAuthorized() && isThreadMember(resource.data.thread_id); - } + match /{path=**}/thread_messages/{docId} { + allow read: if isAuthorized() && isThreadMember(resource.data.thread_id); + } - match /space_threads/{threadId}/thread_messages/{docId} { - allow read: if isAuthorized() && isThreadMember(threadId); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id)); - allow update: if isAuthorized() && isThreadMember(threadId) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["seen_by"]) && - request.resource.data.seen_by is list; - allow create: if isAuthorized() && isThreadMember(threadId) && request.resource.data.sender_id == request.auth.uid && - request.resource.data.keys().hasAll(["id", "thread_id", "sender_id", "message", "seen_by", "created_at"]) && - request.resource.data.id is string && - request.resource.data.thread_id is string && - request.resource.data.sender_id is string && - request.resource.data.message is string && - request.resource.data.seen_by is list && - request.resource.data.created_at is timestamp; - } + match /space_threads/{threadId}/thread_messages/{docId} { + allow read: if isAuthorized() && isThreadMember(threadId); + allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id)); + allow update: if isAuthorized() && isThreadMember(threadId) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["seen_by"]) && + request.resource.data.seen_by is list; + allow create: if isAuthorized() && isThreadMember(threadId) && request.resource.data.sender_id == request.auth.uid && + request.resource.data.keys().hasAll(["id", "thread_id", "sender_id", "message", "seen_by", "created_at"]) && + request.resource.data.id is string && + request.resource.data.thread_id is string && + request.resource.data.sender_id is string && + request.resource.data.message is string && + request.resource.data.seen_by is list && + request.resource.data.created_at is timestamp; } -} \ No newline at end of file + } +} diff --git a/gradle.properties b/gradle.properties index 2cbd6d19..957f8ef9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,4 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +android.enableR8.fullMode=false From e9183ddc25958605d6b93964d9a443af760ffd9e Mon Sep 17 00:00:00 2001 From: cp-megh Date: Mon, 16 Dec 2024 10:54:11 +0530 Subject: [PATCH 04/30] Revert "WIP" This reverts commit 78bd44ad649e97780c2c330062afc65148b9b327. --- app/build.gradle.kts | 6 +- .../detail/UserJourneyDetailViewModel.kt | 2 +- build.gradle.kts | 1 + data/build.gradle.kts | 35 +- .../data/models/location/LocationJourney.kt | 1 + .../yourspace/data/models/space/ApiSpace.kt | 1 - .../yourspace/data/models/user/ApiUser.kt | 19 +- .../security/entity/BaseEncryptedEntity.kt | 8 - .../yourspace/data/security/helper/Helper.kt | 13 - .../data/security/helper/SignalKeyHelper.kt | 192 ------- .../security/session/EncryptedSpaceSession.kt | 110 ---- .../data/service/auth/AuthService.kt | 2 +- .../service/location/ApiJourneyService.kt | 118 +--- .../service/location/ApiLocationService.kt | 190 +------ .../data/service/space/ApiSpaceService.kt | 61 +- .../data/service/user/ApiUserService.kt | 16 +- firestore.rules | 523 +++++++++--------- gradle.properties | 1 - 18 files changed, 358 insertions(+), 941 deletions(-) delete mode 100644 data/src/main/java/com/canopas/yourspace/data/security/entity/BaseEncryptedEntity.kt delete mode 100644 data/src/main/java/com/canopas/yourspace/data/security/helper/Helper.kt delete mode 100644 data/src/main/java/com/canopas/yourspace/data/security/helper/SignalKeyHelper.kt delete mode 100644 data/src/main/java/com/canopas/yourspace/data/security/session/EncryptedSpaceSession.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8f8fa696..7aaae700 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,12 +103,12 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = "17" + jvmTarget = "1.8" } buildFeatures { compose = true diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt index 1177147e..1cd4eb53 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailViewModel.kt @@ -58,7 +58,7 @@ class UserJourneyDetailViewModel @Inject constructor( private fun fetchJourney() = viewModelScope.launch(appDispatcher.IO) { try { _state.value = _state.value.copy(isLoading = true) - val journey = journeyService.getLocationJourneyFromId(journeyId, userId) + val journey = journeyService.getLocationJourneyFromId(journeyId) if (journey == null) { _state.value = _state.value.copy( isLoading = false, diff --git a/build.gradle.kts b/build.gradle.kts index 6161aafc..f4d20266 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,4 +9,5 @@ plugins { id("com.google.firebase.crashlytics") version "3.0.2" apply false id("org.jetbrains.kotlin.jvm") version "1.9.23" id("com.google.devtools.ksp") version "1.9.23-1.0.20" + id("com.google.protobuf") version "0.9.4" apply false } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 8dde311c..f7203dc2 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("com.google.dagger.hilt.android") id("org.jlleitschuh.gradle.ktlint") id("com.google.devtools.ksp") + id("com.google.protobuf") } android { @@ -27,16 +28,19 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = "17" + jvmTarget = "1.8" } ktlint { debug = true } + configurations.all { + resolutionStrategy.force("com.google.protobuf:protobuf-javalite:3.10.0") + } } dependencies { @@ -85,6 +89,27 @@ dependencies { implementation("com.google.android.libraries.places:places:4.0.0") // Signal Protocol - implementation("org.signal:libsignal-client:0.64.1") - implementation("org.signal:libsignal-android:0.64.1") + implementation("org.whispersystems:signal-protocol-android:2.8.1") { + exclude(group = "com.google.protobuf", module = "protolite-java") + } + implementation("com.google.protobuf:protobuf-javalite:3.10.0") // Align with Signal Protocol version + + // AndroidX Security for EncryptedSharedPreferences + implementation("androidx.security:security-crypto:1.1.0-alpha06") + +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.10.0" + } + generateProtoTasks { + all().forEach { task -> + task.builtins { + create("java") { + option("lite") + } + } + } + } } 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 d2b9e617..b3b27907 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 @@ -38,6 +38,7 @@ data class EncryptedLocationJourney( val updated_at: Long? = System.currentTimeMillis() ) + @Keep data class JourneyRoute(val latitude: Double = 0.0, val longitude: Double = 0.0) 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 692aabe2..0d83f35b 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 @@ -10,7 +10,6 @@ data class ApiSpace( val id: String = UUID.randomUUID().toString(), val admin_id: String = "", val name: String = "", - val encryptedSenderKeys: Map> = emptyMap(), // User-specific encrypted keys val created_at: Long? = System.currentTimeMillis() ) diff --git a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt index c00be17a..e35e5e98 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt @@ -3,9 +3,6 @@ package com.canopas.yourspace.data.models.user import androidx.annotation.Keep import com.google.firebase.firestore.Exclude import com.squareup.moshi.JsonClass -import org.signal.libsignal.protocol.IdentityKeyPair -import org.signal.libsignal.protocol.state.PreKeyRecord -import org.signal.libsignal.protocol.state.SignedPreKeyRecord import java.util.UUID const val LOGIN_TYPE_GOOGLE = 1 @@ -27,12 +24,7 @@ data class ApiUser( val state: Int = USER_STATE_UNKNOWN, val battery_pct: Float? = 0f, val created_at: Long? = System.currentTimeMillis(), - val updated_at: Long? = System.currentTimeMillis(), - val public_key: String? = null, // Identity public key (Base64-encoded) - val private_key: String? = null, // Identity private key (Base64-encoded and encrypted) - val pre_keys: List? = null, // List of serialized PreKeys (Base64-encoded) - val signed_pre_key: String? = null, // Serialized Signed PreKey (Base64-encoded) - val registration_id: Int = 0 // Signal Protocol registration ID + val updated_at: Long? = System.currentTimeMillis() ) { @get:Exclude val fullName: String get() = "$first_name $last_name" @@ -47,15 +39,6 @@ data class ApiUser( val locationPermissionDenied: Boolean get() = state == USER_STATE_LOCATION_PERMISSION_DENIED } -@Keep -@JsonClass(generateAdapter = false) -data class SignalKeys( - val identityKeyPair: IdentityKeyPair, - val signedPreKey: SignedPreKeyRecord, - val preKeys: List, - val registrationId: Int -) - @Keep @JsonClass(generateAdapter = true) data class ApiUserSession( diff --git a/data/src/main/java/com/canopas/yourspace/data/security/entity/BaseEncryptedEntity.kt b/data/src/main/java/com/canopas/yourspace/data/security/entity/BaseEncryptedEntity.kt deleted file mode 100644 index 5989dda5..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/security/entity/BaseEncryptedEntity.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.canopas.yourspace.data.security.entity - -import org.signal.libsignal.protocol.SignalProtocolAddress - -abstract class BaseEncryptedEntity protected constructor( - val registrationId: Int, - val signalProtocolAddress: SignalProtocolAddress -) diff --git a/data/src/main/java/com/canopas/yourspace/data/security/helper/Helper.kt b/data/src/main/java/com/canopas/yourspace/data/security/helper/Helper.kt deleted file mode 100644 index 2d257ebd..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/security/helper/Helper.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.canopas.yourspace.data.security.helper - -import android.util.Base64 - -object Helper { - fun encodeToBase64(value: ByteArray?): String { - return Base64.encodeToString(value, Base64.NO_WRAP) - } - - fun decodeToByteArray(base64: String?): ByteArray { - return Base64.decode(base64, Base64.NO_WRAP) - } -} diff --git a/data/src/main/java/com/canopas/yourspace/data/security/helper/SignalKeyHelper.kt b/data/src/main/java/com/canopas/yourspace/data/security/helper/SignalKeyHelper.kt deleted file mode 100644 index 02c2a8e8..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/security/helper/SignalKeyHelper.kt +++ /dev/null @@ -1,192 +0,0 @@ -package com.canopas.yourspace.data.security.helper - -import android.util.Base64 -import com.canopas.yourspace.data.models.user.ApiUser -import com.canopas.yourspace.data.models.user.SignalKeys -import org.signal.libsignal.protocol.IdentityKey -import org.signal.libsignal.protocol.IdentityKeyPair -import org.signal.libsignal.protocol.InvalidKeyException -import org.signal.libsignal.protocol.SignalProtocolAddress -import org.signal.libsignal.protocol.ecc.Curve -import org.signal.libsignal.protocol.groups.GroupSessionBuilder -import org.signal.libsignal.protocol.groups.state.SenderKeyRecord -import org.signal.libsignal.protocol.state.PreKeyRecord -import org.signal.libsignal.protocol.state.SignedPreKeyRecord -import org.signal.libsignal.protocol.state.impl.InMemorySignalProtocolStore -import org.signal.libsignal.protocol.util.KeyHelper -import org.signal.libsignal.protocol.util.Medium -import java.util.LinkedList -import java.util.Random -import java.util.UUID -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.SecretKeySpec -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class SignalKeyHelper @Inject constructor() { - - private fun generateIdentityKeyPair(): IdentityKeyPair { - val djbKeyPair = Curve.generateKeyPair() - val djbIdentityKey = IdentityKey(djbKeyPair.publicKey) - val djbPrivateKey = djbKeyPair.privateKey - - return IdentityKeyPair(djbIdentityKey, djbPrivateKey) - } - - @Throws(InvalidKeyException::class) - fun generateSignedPreKey( - identityKeyPair: IdentityKeyPair, - signedPreKeyId: Int - ): SignedPreKeyRecord { - val keyPair = Curve.generateKeyPair() - val signature = - Curve.calculateSignature(identityKeyPair.privateKey, keyPair.publicKey.serialize()) - return SignedPreKeyRecord(signedPreKeyId, System.currentTimeMillis(), keyPair, signature) - } - - private fun generatePreKeys(): List { - val records: MutableList = LinkedList() - val preKeyIdOffset = Random().nextInt(Medium.MAX_VALUE - 101) - for (i in 0 until 100) { - val preKeyId = (preKeyIdOffset + i) % Medium.MAX_VALUE - val keyPair = Curve.generateKeyPair() - val record = PreKeyRecord(preKeyId, keyPair) - - records.add(record) - } - - return records - } - - fun generateSignalKeys(): SignalKeys { - val identityKeyPair = generateIdentityKeyPair() - val signedPreKey = generateSignedPreKey(identityKeyPair, Random().nextInt(Medium.MAX_VALUE - 1)) - val preKeys = generatePreKeys() - val registrationId = KeyHelper.generateRegistrationId(false) - - return SignalKeys( - identityKeyPair = identityKeyPair, - signedPreKey = signedPreKey, - preKeys = preKeys, - registrationId = registrationId - ) - } - - fun createDistributionKey( - user: ApiUser, - deviceId: String, - spaceId: String - ): Pair { - val signalProtocolAddress = SignalProtocolAddress(user.id, deviceId.hashCode()) - val identityKeyPair = IdentityKeyPair( - IdentityKey(Curve.decodePoint(Base64.decode(user.public_key, Base64.DEFAULT), 0)), - Curve.decodePrivatePoint(Base64.decode(user.private_key, Base64.DEFAULT)) - ) - val signalProtocolStore = InMemorySignalProtocolStore(identityKeyPair, user.registration_id) - val signedPreKeyId = SignedPreKeyRecord(Helper.decodeToByteArray(user.signed_pre_key)).id - val preKeys = SignedPreKeyRecord(Helper.decodeToByteArray(user.signed_pre_key)) - signalProtocolStore.storeSignedPreKey(signedPreKeyId, preKeys) - - user.pre_keys?.let { preKeyRecords -> - val deserializedPreKeys = - preKeyRecords.map { PreKeyRecord(Helper.decodeToByteArray(it)) } - for (record in deserializedPreKeys) { - signalProtocolStore.storePreKey(record.id, record) - } - } - val validSpaceId = try { - UUID.fromString(spaceId) // Validate if it's a proper UUID string - } catch (e: IllegalArgumentException) { - UUID.randomUUID() // Fallback to a new valid UUID if parsing fails - } - signalProtocolStore.storeSenderKey( - signalProtocolAddress, - validSpaceId, - SenderKeyRecord(Helper.decodeToByteArray(user.signed_pre_key)) - ) - - val sessionBuilder = GroupSessionBuilder(signalProtocolStore) - val senderKeyDistributionMessage = - sessionBuilder.create(signalProtocolAddress, validSpaceId) - val senderKeyRecord = signalProtocolStore.loadSenderKey(signalProtocolAddress, validSpaceId) - - return Pair( - Helper.encodeToBase64(senderKeyDistributionMessage.serialize()), - Helper.encodeToBase64(senderKeyRecord.serialize()) - ) - } - - private fun encryptAESKeyWithECDH( - aesKey: SecretKey, - publicKey: String, - senderPrivateKey: String - ): String { - val ecPublicKey = Curve.decodePoint(Base64.decode(publicKey, Base64.DEFAULT), 0) - val ecPrivateKey = Curve.decodePrivatePoint(Base64.decode(senderPrivateKey, Base64.DEFAULT)) - val sharedSecret = Curve.calculateAgreement(ecPublicKey, ecPrivateKey) - val secretKeySpec = SecretKeySpec(sharedSecret, 0, 32, "AES") - val cipher = Cipher.getInstance("AES") - cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec) - val encryptedAESKey = cipher.doFinal(aesKey.encoded) - - return Base64.encodeToString(encryptedAESKey, Base64.NO_WRAP) - } - - private fun decryptAESKeyWithECDH( - encryptedAESKey: String, - privateKey: String, - senderPublicKey: String - ): SecretKey { - val ecPublicKey = Curve.decodePoint(Base64.decode(senderPublicKey, Base64.DEFAULT), 0) - val ecPrivateKey = Curve.decodePrivatePoint(Base64.decode(privateKey, Base64.DEFAULT)) - val sharedSecret = Curve.calculateAgreement(ecPublicKey, ecPrivateKey) - val secretKeySpec = SecretKeySpec(sharedSecret, 0, 32, "AES") - val cipher = Cipher.getInstance("AES") - cipher.init(Cipher.DECRYPT_MODE, secretKeySpec) - val decryptedAESKeyBytes = cipher.doFinal(Base64.decode(encryptedAESKey, Base64.DEFAULT)) - - return SecretKeySpec(decryptedAESKeyBytes, "AES") - } - - fun encryptSenderKeyForGroup( - senderKey: String, - senderPrivateKey: String, - recipients: List - ): Map> { - val encryptedKeys = mutableMapOf>() - val keyGen = KeyGenerator.getInstance("AES") - val aesKey: SecretKey = keyGen.generateKey() - val cipher = Cipher.getInstance("AES") - cipher.init(Cipher.ENCRYPT_MODE, aesKey) - val encryptedSenderKey = - Base64.encodeToString(cipher.doFinal(senderKey.toByteArray()), Base64.NO_WRAP) - recipients.forEach { recipient -> - recipient?.let { - val recipientPublicKey = recipient.public_key!! - val encryptedAESKey = - encryptAESKeyWithECDH(aesKey, recipientPublicKey, senderPrivateKey) - encryptedKeys[recipient.id] = Pair(encryptedSenderKey, encryptedAESKey) - } - } - - return encryptedKeys - } - - fun decryptSenderKey( - encryptedSenderKey: String, - encryptedAESKey: String, - recipientPrivateKey: String, - senderPublicKey: String - ): String { - val aesKey = decryptAESKeyWithECDH(encryptedAESKey, recipientPrivateKey, senderPublicKey) - val cipher = Cipher.getInstance("AES") - cipher.init(Cipher.DECRYPT_MODE, aesKey) - val decryptedSenderKeyBytes = - cipher.doFinal(Base64.decode(encryptedSenderKey, Base64.DEFAULT)) - - return String(decryptedSenderKeyBytes) - } -} diff --git a/data/src/main/java/com/canopas/yourspace/data/security/session/EncryptedSpaceSession.kt b/data/src/main/java/com/canopas/yourspace/data/security/session/EncryptedSpaceSession.kt deleted file mode 100644 index 27b9eb40..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/security/session/EncryptedSpaceSession.kt +++ /dev/null @@ -1,110 +0,0 @@ -package com.canopas.yourspace.data.security.session - -import android.util.Base64 -import androidx.annotation.Keep -import com.canopas.yourspace.data.models.user.ApiUser -import com.canopas.yourspace.data.security.helper.Helper -import com.squareup.moshi.JsonClass -import org.signal.libsignal.protocol.IdentityKey -import org.signal.libsignal.protocol.IdentityKeyPair -import org.signal.libsignal.protocol.NoSessionException -import org.signal.libsignal.protocol.SignalProtocolAddress -import org.signal.libsignal.protocol.ecc.Curve -import org.signal.libsignal.protocol.groups.GroupCipher -import org.signal.libsignal.protocol.groups.GroupSessionBuilder -import org.signal.libsignal.protocol.groups.state.SenderKeyRecord -import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage -import org.signal.libsignal.protocol.state.PreKeyRecord -import org.signal.libsignal.protocol.state.SignedPreKeyRecord -import org.signal.libsignal.protocol.state.impl.InMemorySignalProtocolStore -import java.nio.charset.StandardCharsets -import java.util.UUID - -class EncryptedSpaceSession( - currentUser: ApiUser, - keyRecord: String?, - spaceId: String -) { - private val spaceDistributionId: UUID = try { - UUID.fromString(spaceId) // Validate if it's a proper UUID string - } catch (e: IllegalArgumentException) { - UUID.randomUUID() // Fallback to a new valid UUID if parsing fails - } - private val signalProtocolAddress = SignalProtocolAddress(currentUser.id, 1) - private val identityKeyPair = IdentityKeyPair( - IdentityKey(Curve.decodePoint(Base64.decode(currentUser.public_key, Base64.DEFAULT), 0)), - Curve.decodePrivatePoint(Base64.decode(currentUser.private_key, Base64.DEFAULT)) - ) - - private val protocolStore = InMemorySignalProtocolStore(identityKeyPair, currentUser.registration_id) - private val encryptGroupCipher: GroupCipher - private val decryptCiphers = mutableMapOf() - private val distributionStore = mutableMapOf() - private val sessionBuilder = GroupSessionBuilder(protocolStore) - - init { - val signedPreKey = SignedPreKeyRecord(Helper.decodeToByteArray(currentUser.signed_pre_key)) - protocolStore.storeSignedPreKey(signedPreKey.id, signedPreKey) - - currentUser.pre_keys?.forEach { preKey -> - val record = PreKeyRecord(Helper.decodeToByteArray(preKey)) - protocolStore.storePreKey(record.id, record) - } - - if (keyRecord != null) { - protocolStore.storeSenderKey( - signalProtocolAddress, - spaceDistributionId, - SenderKeyRecord(Helper.decodeToByteArray(keyRecord)) - ) - } - - encryptGroupCipher = GroupCipher(protocolStore, signalProtocolAddress) - } - - val keyRecord: String - get() { - val record = protocolStore.loadSenderKey(signalProtocolAddress, spaceDistributionId) - return Helper.encodeToBase64(record.serialize()) - } - - fun createSession(members: List) { - members.forEach { member -> - val distributionKey = Helper.decodeToByteArray(member.keyDistributionMessage) - val keyMessage = SenderKeyDistributionMessage(distributionKey) - val address = SignalProtocolAddress(member.userId, 1) - - if (!decryptCiphers.containsKey(member.userId) || - distributionStore[member.userId] != member.keyDistributionMessage - ) { - distributionStore.remove(member.userId) - decryptCiphers.remove(member.userId) - - sessionBuilder.process(address, keyMessage) - distributionStore[member.userId] = member.keyDistributionMessage - decryptCiphers[member.userId] = GroupCipher(protocolStore, address) - } - } - } - - fun encryptMessage(message: String): String { - val encrypted = encryptGroupCipher.encrypt( - spaceDistributionId, - message.toByteArray(StandardCharsets.UTF_8) - ) - return Helper.encodeToBase64(encrypted.serialize()) - } - - fun decryptMessage(encryptedMessage: String?, userId: String): String { - val cipher = decryptCiphers[userId] ?: throw NoSessionException("No cipher for user $userId") - val decrypted = cipher.decrypt(Helper.decodeToByteArray(encryptedMessage)) - return String(decrypted, StandardCharsets.UTF_8) - } -} - -@Keep -@JsonClass(generateAdapter = false) -data class SpaceKeyDistribution( - val userId: String, - val keyDistributionMessage: String -) 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 0923b2e2..311e74b5 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 @@ -75,7 +75,7 @@ class AuthService @Inject constructor( userPreferences.currentUser = newUser } - var currentUserSession: ApiUserSession? + private var currentUserSession: ApiUserSession? get() { return userPreferences.currentUserSession } 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 32b59605..71050bc8 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 @@ -1,16 +1,7 @@ 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.canopas.yourspace.data.models.space.ApiSpace -import com.canopas.yourspace.data.models.space.ApiSpaceMember -import com.canopas.yourspace.data.models.user.ApiUser -import com.canopas.yourspace.data.security.helper.SignalKeyHelper -import com.canopas.yourspace.data.security.session.EncryptedSpaceSession -import com.canopas.yourspace.data.security.session.SpaceKeyDistribution -import com.canopas.yourspace.data.service.user.ApiUserService import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES @@ -18,6 +9,7 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBER import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.toObject +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.tasks.await import timber.log.Timber import javax.inject.Inject @@ -26,9 +18,7 @@ import javax.inject.Singleton @Singleton class ApiJourneyService @Inject constructor( db: FirebaseFirestore, - private val userPreferences: UserPreferences, - private val signalKeyHelper: SignalKeyHelper, - private val apiUserService: ApiUserService + private val userPreferences: UserPreferences ) { var currentSpaceId: String = userPreferences.currentSpace ?: "" @@ -37,14 +27,14 @@ class ApiJourneyService @Inject constructor( // Document references must have an even number of segments, but users has 1 // https://stackoverflow.com/a/51195713/22508023 [Explanation can be found in comments] private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) - private fun spaceMemberRef(spaceId: String) = + internal fun spaceMemberRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( FIRESTORE_COLLECTION_SPACE_MEMBERS ) - private fun spaceMemberJourneyRef(spaceId: String, userId: String) = + private fun spaceMemberJourneyRef(spaceId: String) = spaceMemberRef(spaceId) - .document(userId.takeIf { it.isNotBlank() } ?: "null") + .document(currentSpaceId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_JOURNEYS) suspend fun saveCurrentJourney( @@ -60,93 +50,33 @@ class ApiJourneyService @Inject constructor( updateAt: Long? = null, newJourneyId: ((String) -> Unit)? = null ) { - val user = userPreferences.currentUser ?: return - val userDeviceId = userPreferences.currentUserSession?.device_id ?: return - userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val (_, senderKeyRecord) = signalKeyHelper.createDistributionKey( - user = user, - deviceId = userDeviceId, - spaceId = spaceId - ) - val spaceSession = EncryptedSpaceSession( - currentUser = user, - keyRecord = senderKeyRecord, - spaceId = spaceId - ) - val spaceMembers = spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) - val mSenderKeyDistributionModel = ArrayList().apply { - spaceMembers.forEach { member -> - val memberUser = apiUserService.getUser(member.user_id) ?: return - val decryptedSenderKey = getDecryptedSenderKey( - spaceId, - memberUser, - memberUser.public_key!! - ) - add( - SpaceKeyDistribution( - member.user_id, - decryptedSenderKey - ) - ) - } - } - spaceSession.createSession(mSenderKeyDistributionModel) - val encryptedFromLatitude = spaceSession.encryptMessage(fromLatitude.toString()) - val encryptedFromLongitude = spaceSession.encryptMessage(fromLongitude.toString()) - val encryptedToLatitude = toLatitude?.let { spaceSession.encryptMessage(it.toString()) } - val encryptedToLongitude = toLongitude?.let { spaceSession.encryptMessage(it.toString()) } - val encryptedJourneyRoutes = routes.map { - EncryptedJourneyRoute( - encrypted_latitude = spaceSession.encryptMessage(it.latitude.toString()), - encrypted_longitude = spaceSession.encryptMessage(it.longitude.toString()) - ) - } - - val docRef = spaceMemberJourneyRef(spaceId, userId).document() + userPreferences.currentUser?.space_ids?.forEach { + val docRef = spaceMemberJourneyRef(it).document() - val encryptedJourney = EncryptedLocationJourney( + val journey = LocationJourney( id = docRef.id, user_id = userId, - encrypted_from_latitude = encryptedFromLatitude, - encrypted_from_longitude = encryptedFromLongitude, - encrypted_to_latitude = encryptedToLatitude, - encrypted_to_longitude = encryptedToLongitude, + from_latitude = fromLatitude, + from_longitude = fromLongitude, + to_latitude = toLatitude, + to_longitude = toLongitude, route_distance = routeDistance, route_duration = routeDuration, - encrypted_routes = encryptedJourneyRoutes, + routes = routes, created_at = createdAt ?: System.currentTimeMillis(), - updated_at = updateAt ?: System.currentTimeMillis() + update_at = updateAt ?: System.currentTimeMillis() ) - newJourneyId?.invoke(encryptedJourney.id) + newJourneyId?.invoke(journey.id) - docRef.set(encryptedJourney).await() + docRef.set(journey).await() } } - private suspend fun getDecryptedSenderKey( - spaceId: String, - recipient: ApiUser, - senderPublicKey: String - ): String { - val space = spaceRef.document(spaceId).get().await().toObject(ApiSpace::class.java) - ?: throw Exception("Space not found") - - val encryptedKeys = space.encryptedSenderKeys[recipient.id] - ?: throw Exception("No keys found for recipient") - - return signalKeyHelper.decryptSenderKey( - encryptedSenderKey = encryptedKeys["encryptedSenderKey"]!!, - encryptedAESKey = encryptedKeys["encryptedAESKey"]!!, - recipientPrivateKey = recipient.private_key!!, - senderPublicKey = senderPublicKey - ) - } - suspend fun updateLastLocationJourney(userId: String, journey: LocationJourney) { try { userPreferences.currentUser?.space_ids?.forEach { - spaceMemberJourneyRef(it, userId).document(journey.id).set(journey).await() + spaceMemberJourneyRef(it).document(journey.id).set(journey).await() } } catch (e: Exception) { Timber.e(e, "Error while updating last location journey") @@ -154,7 +84,7 @@ class ApiJourneyService @Inject constructor( } suspend fun getLastJourneyLocation(userId: String) = try { - spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING).limit(1) .get().await().documents.firstOrNull()?.toObject() } catch (e: Exception) { @@ -167,11 +97,11 @@ class ApiJourneyService @Inject constructor( from: Long? ): List { val query = if (from == null) { - spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) .limit(20) } else { - spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) .whereLessThan("created_at", from) .limit(20) @@ -184,13 +114,13 @@ class ApiJourneyService @Inject constructor( from: Long, to: Long ): List { - val previousDayJourney = spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) + val previousDayJourney = spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .whereLessThan("created_at", from) .whereGreaterThanOrEqualTo("update_at", from) .limit(1) .get().await().documents.mapNotNull { it.toObject() } - val currentDayJourney = spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) + val currentDayJourney = spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) .whereGreaterThanOrEqualTo("created_at", from) .whereLessThanOrEqualTo("created_at", to) .orderBy("created_at", Query.Direction.DESCENDING) @@ -200,8 +130,8 @@ class ApiJourneyService @Inject constructor( return previousDayJourney + currentDayJourney } - suspend fun getLocationJourneyFromId(journeyId: String, userId: String): LocationJourney? { - return spaceMemberJourneyRef(currentSpaceId, userId = userId).document(journeyId).get().await() + suspend fun getLocationJourneyFromId(journeyId: String): LocationJourney? { + return spaceMemberJourneyRef(currentSpaceId).document(journeyId).get().await() .toObject(LocationJourney::class.java) } } 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 e7c76b30..1c8cd874 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 @@ -1,23 +1,15 @@ package com.canopas.yourspace.data.service.location +import android.util.Log import com.canopas.yourspace.data.models.location.ApiLocation -import com.canopas.yourspace.data.models.location.EncryptedApiLocation -import com.canopas.yourspace.data.models.space.ApiSpace -import com.canopas.yourspace.data.models.space.ApiSpaceMember -import com.canopas.yourspace.data.models.user.ApiUser -import com.canopas.yourspace.data.security.helper.SignalKeyHelper -import com.canopas.yourspace.data.security.session.EncryptedSpaceSession -import com.canopas.yourspace.data.security.session.SpaceKeyDistribution import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS -import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_USERS import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await import timber.log.Timber import javax.inject.Inject @@ -27,79 +19,36 @@ import javax.inject.Singleton class ApiLocationService @Inject constructor( db: FirebaseFirestore, private val locationManager: LocationManager, - private val userPreferences: UserPreferences, - private val signalKeyHelper: SignalKeyHelper + private val userPreferences: UserPreferences ) { var currentSpaceId: String = userPreferences.currentSpace ?: "" private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) - private val userRef = db.collection(FIRESTORE_COLLECTION_USERS) private fun spaceMemberRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( FIRESTORE_COLLECTION_SPACE_MEMBERS ) - private fun spaceMemberLocationRef(spaceId: String, userId: String) = + private fun spaceMemberLocationRef(spaceId: String) = spaceMemberRef(spaceId) - .document(userId.takeIf { it.isNotBlank() } ?: "null") + .document(currentSpaceId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_LOCATIONS) suspend fun saveLastKnownLocation( userId: String ) { val lastLocation = locationManager.getLastLocation() ?: return - val user = userPreferences.currentUser ?: return - val userDeviceId = userPreferences.currentUserSession?.device_id ?: return - userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val (_, senderKeyRecord) = signalKeyHelper.createDistributionKey( - user = user, - deviceId = userDeviceId, - spaceId = spaceId - ) - val spaceSession = EncryptedSpaceSession( - currentUser = user, - keyRecord = senderKeyRecord, - spaceId = spaceId - ) - val spaceMembers = spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) - val mSenderKeyDistributionModel = ArrayList().apply { - spaceMembers.forEach { member -> - val memberUser = getUser(member.user_id) ?: return - val decryptedSenderKey = getDecryptedSenderKey( - spaceId, - memberUser, - memberUser.public_key!! - ) - add( - SpaceKeyDistribution( - member.user_id, - decryptedSenderKey - ) - ) - } - } - spaceSession.createSession(mSenderKeyDistributionModel) - val encryptedLatitude = spaceSession.encryptMessage(lastLocation.latitude.toString()) - val encryptedLongitude = spaceSession.encryptMessage(lastLocation.longitude.toString()) - val docRef = spaceMemberLocationRef(spaceId, userId).document() + userPreferences.currentUser?.space_ids?.forEach { + val docRef = spaceMemberLocationRef(it).document() - val encryptedLocation = EncryptedApiLocation( + val location = ApiLocation( id = docRef.id, user_id = userId, - encrypted_latitude = encryptedLatitude, - encrypted_longitude = encryptedLongitude, + latitude = lastLocation.latitude, + longitude = lastLocation.longitude, created_at = System.currentTimeMillis() ) - docRef.set(encryptedLocation).await() - } - } - - suspend fun getUser(userId: String): ApiUser? { - return try { - userRef.document(userId).get().await().toObject(ApiUser::class.java) - } catch (e: Exception) { - Timber.e(e, "Error while getting user") - null + docRef.set(location).await() } } @@ -109,119 +58,30 @@ class ApiLocationService @Inject constructor( longitude: Double, recordedAt: Long ) { - val user = userPreferences.currentUser ?: return - val userDeviceId = userPreferences.currentUserSession?.device_id ?: return - userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val (_, senderKeyRecord) = signalKeyHelper.createDistributionKey( - user = user, - deviceId = userDeviceId, - spaceId = spaceId - ) - val spaceSession = EncryptedSpaceSession( - currentUser = user, - keyRecord = senderKeyRecord, - spaceId = spaceId - ) - val spaceMembers = spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) - val mSenderKeyDistributionModel = ArrayList().apply { - spaceMembers.forEach { member -> - val memberUser = getUser(member.user_id) ?: return - val decryptedSenderKey = getDecryptedSenderKey( - spaceId, - memberUser, - memberUser.public_key!! - ) - add( - SpaceKeyDistribution( - member.user_id, - decryptedSenderKey - ) - ) - } - } - spaceSession.createSession(mSenderKeyDistributionModel) - val encryptedLatitude = spaceSession.encryptMessage(latitude.toString()) - val encryptedLongitude = spaceSession.encryptMessage(longitude.toString()) - val docRef = spaceMemberLocationRef(spaceId, userId).document() + Log.e("XXXXXX", "SpaceId: $currentSpaceId") + userPreferences.currentUser?.space_ids?.forEach { + val docRef = spaceMemberLocationRef(it).document() - val encryptedLocation = EncryptedApiLocation( + val location = ApiLocation( id = docRef.id, user_id = userId, - encrypted_latitude = encryptedLatitude, - encrypted_longitude = encryptedLongitude, + latitude = latitude, + longitude = longitude, created_at = recordedAt ) - docRef.set(encryptedLocation).await() + docRef.set(location).await() } } - private suspend fun getDecryptedSenderKey( - spaceId: String, - recipient: ApiUser, - senderPublicKey: String - ): String { - val space = spaceRef.document(spaceId).get().await().toObject(ApiSpace::class.java) - ?: throw Exception("Space not found") - - val encryptedKeys = space.encryptedSenderKeys[recipient.id] - ?: throw Exception("No keys found for recipient") - - return signalKeyHelper.decryptSenderKey( - encryptedSenderKey = encryptedKeys["encryptedSenderKey"]!!, - encryptedAESKey = encryptedKeys["encryptedAESKey"]!!, - recipientPrivateKey = recipient.private_key!!, - senderPublicKey = senderPublicKey - ) - } - - fun getCurrentLocation(userId: String): Flow> { - return flow { - try { - val encryptedLocation = spaceMemberLocationRef(currentSpaceId, userId) - .whereEqualTo("user_id", userId) - .orderBy("created_at", Query.Direction.DESCENDING) - .limit(1) - .snapshotFlow(EncryptedApiLocation::class.java) - - encryptedLocation.collect { encryptedLocationList -> - val apiLocations = encryptedLocationList.mapNotNull { encryptedLocation -> - val user = getUser(encryptedLocation.user_id) ?: return@mapNotNull null - val senderPublicKey = user.public_key ?: return@mapNotNull null - val space = spaceRef.document(currentSpaceId).get().await().toObject(ApiSpace::class.java) - ?: throw Exception("Space not found") - - val encryptedKeys = space.encryptedSenderKeys[user.id] - ?: throw Exception("No keys found for recipient") - - val decryptedSenderKey = signalKeyHelper.decryptSenderKey( - encryptedSenderKey = encryptedKeys["encryptedSenderKey"]!!, - encryptedAESKey = encryptedKeys["encryptedAESKey"]!!, - recipientPrivateKey = user.private_key!!, - senderPublicKey = senderPublicKey - ) - - val spaceSession = EncryptedSpaceSession( - currentUser = user, - keyRecord = decryptedSenderKey, - spaceId = currentSpaceId - ) - val decryptedLatitude = spaceSession.decryptMessage(encryptedLocation.encrypted_latitude, user.id) - val decryptedLongitude = spaceSession.decryptMessage(encryptedLocation.encrypted_longitude, user.id) - - ApiLocation( - user_id = user.id, - latitude = decryptedLatitude.toDouble(), - longitude = decryptedLongitude.toDouble() - ) - } - - emit(apiLocations) // Emit the list of ApiLocation - } - } catch (e: Exception) { - Timber.e(e, "Error while getting current location") - emit(emptyList()) // Emit an empty list in case of an error - } + fun getCurrentLocation(userId: String): Flow>? { + return try { + spaceMemberLocationRef(currentSpaceId).whereEqualTo("user_id", userId) + .orderBy("created_at", Query.Direction.DESCENDING).limit(1) + .snapshotFlow(ApiLocation::class.java) + } catch (e: Exception) { + Timber.e(e, "Error while getting current location") + null } } } 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 ab0f19ce..cb9cdf3d 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 @@ -4,8 +4,6 @@ import com.canopas.yourspace.data.models.space.ApiSpace import com.canopas.yourspace.data.models.space.ApiSpaceMember import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_ADMIN import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_MEMBER -import com.canopas.yourspace.data.models.user.ApiUser -import com.canopas.yourspace.data.security.helper.SignalKeyHelper import com.canopas.yourspace.data.service.auth.AuthService import com.canopas.yourspace.data.service.place.ApiPlaceService import com.canopas.yourspace.data.service.user.ApiUserService @@ -13,8 +11,6 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.FirebaseFirestore -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.tasks.await import javax.inject.Inject import javax.inject.Singleton @@ -24,11 +20,10 @@ class ApiSpaceService @Inject constructor( private val db: FirebaseFirestore, private val authService: AuthService, private val apiUserService: ApiUserService, - private val placeService: ApiPlaceService, - private val signalKeyHelper: SignalKeyHelper + private val placeService: ApiPlaceService ) { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) - private fun spaceMemberRef(spaceId: String) = + internal fun spaceMemberRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) suspend fun createSpace(spaceName: String): String { @@ -54,45 +49,9 @@ class ApiSpaceService @Inject constructor( it.set(member).await() } - val user = authService.currentUser ?: return - val userDeviceId = authService.currentUserSession?.device_id ?: return - val (senderKey, _) = signalKeyHelper.createDistributionKey( - user = user, - deviceId = userDeviceId, - spaceId = spaceId - ) - val spaceMembers = getMemberBySpaceId(spaceId).firstOrNull()?.map { - apiUserService.getUser(it.user_id) ?: return@map null - } - distributeSenderKeyToGroup( - spaceId = spaceId, - senderKey = senderKey, - senderPrivateKey = user.private_key!!, - members = spaceMembers ?: emptyList() - ) apiUserService.addSpaceId(userId, spaceId) } - private suspend fun distributeSenderKeyToGroup( - spaceId: String, - senderKey: String, - senderPrivateKey: String, - members: List - ) { - // Encrypt the Sender Key for each recipient - val encryptedKeys = signalKeyHelper.encryptSenderKeyForGroup(senderKey, senderPrivateKey, members) - val encryptedSenderKeysMap = encryptedKeys.mapValues { entry -> - mapOf( - "encryptedSenderKey" to entry.value.first, - "encryptedAESKey" to entry.value.second - ) - } - - spaceRef.document(spaceId) - .update("encryptedSenderKeys", encryptedSenderKeysMap) - .await() - } - suspend fun enableLocation(spaceId: String, userId: String, enable: Boolean) { spaceMemberRef(spaceId) .whereEqualTo("user_id", userId).get() @@ -136,22 +95,6 @@ class ApiSpaceService @Inject constructor( .whereEqualTo("user_id", userId).get().await().documents.forEach { it.reference.delete().await() } - val user = authService.currentUser ?: return - val userDeviceId = authService.currentUserSession?.device_id ?: return - val (senderKey, _) = signalKeyHelper.createDistributionKey( - user = user, - deviceId = userDeviceId, - spaceId = spaceId - ) - val spaceMembers = getMemberBySpaceId(spaceId).firstOrNull()?.map { - apiUserService.getUser(it.user_id) ?: return@map null - } - distributeSenderKeyToGroup( - spaceId = spaceId, - senderKey = senderKey, - senderPrivateKey = user.private_key!!, - members = spaceMembers ?: emptyList() - ) } 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 032eb8fb..9f52f4ab 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 @@ -4,8 +4,6 @@ import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.models.user.ApiUserSession import com.canopas.yourspace.data.models.user.LOGIN_TYPE_APPLE import com.canopas.yourspace.data.models.user.LOGIN_TYPE_GOOGLE -import com.canopas.yourspace.data.security.helper.Helper -import com.canopas.yourspace.data.security.helper.SignalKeyHelper import com.canopas.yourspace.data.service.location.ApiLocationService import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_USERS @@ -31,7 +29,6 @@ class ApiUserService @Inject constructor( db: FirebaseFirestore, private val device: Device, private val locationService: ApiLocationService, - private val signalKeyHelper: SignalKeyHelper, private val functions: FirebaseFunctions ) { private val userRef = db.collection(FIRESTORE_COLLECTION_USERS) @@ -77,12 +74,6 @@ class ApiUserService @Inject constructor( sessionDocRef.set(session).await() return Triple(false, savedUser, session) } else { - val signalKeys = signalKeyHelper.generateSignalKeys() - val publicKey = Helper.encodeToBase64(signalKeys.identityKeyPair.publicKey.serialize()) - val privateKey = Helper.encodeToBase64(signalKeys.identityKeyPair.privateKey.serialize()) - val preKeys = signalKeys.preKeys.map { Helper.encodeToBase64(it.serialize()) } - val signedPreKey = Helper.encodeToBase64(signalKeys.signedPreKey.serialize()) - val user = ApiUser( id = uid!!, email = account?.email ?: firebaseUser?.email ?: "", @@ -91,12 +82,7 @@ class ApiUserService @Inject constructor( last_name = account?.familyName ?: "", provider_firebase_id_token = firebaseToken, profile_image = account?.photoUrl?.toString() ?: firebaseUser?.photoUrl?.toString() - ?: "", - public_key = publicKey, - private_key = privateKey, - pre_keys = preKeys, - signed_pre_key = signedPreKey, - registration_id = signalKeys.registrationId + ?: "" ) userRef.document(uid).set(user).await() val sessionDocRef = sessionRef(user.id).document() diff --git a/firestore.rules b/firestore.rules index fb0c0c18..4cb4670c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,280 +1,293 @@ rules_version = '2'; service cloud.firestore { - match /databases/{database}/documents { + match /databases/{database}/documents { - function isAuthorized() { - return request.auth != null; - } + function isAuthorized() { + return request.auth != null; + } - function readUserLocation() { - let requestedUserSpaceIds = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.space_ids; - let resourceUserSpaceIds = get(/databases/$(database)/documents/users/$(resource.data.user_id)).data.space_ids; - return requestedUserSpaceIds.hasAny(resourceUserSpaceIds); - } + function readUserLocation() { + let requestedUserSpaceIds = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.space_ids; + let resourceUserSpaceIds = get(/databases/$(database)/documents/users/$(resource.data.user_id)).data.space_ids; + return requestedUserSpaceIds.hasAny(resourceUserSpaceIds); + } - match /support_requests/{docId} { - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["user_id", "title", "description", "device_name", "app_version", "device_os", "created_at"]) && - request.resource.data.user_id is string && - request.resource.data.title is string && - request.resource.data.description is string && - request.resource.data.device_name is string && - request.resource.data.app_version is string && - request.resource.data.device_os is string && - request.resource.data.created_at is timestamp && - request.resource.data.get('attachments', []) is list; - allow update: if false; - allow delete: if false; - allow read: if isAuthorized() && request.auth.uid == resource.data.user_id; - } + match /support_requests/{docId} { + allow create : if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["user_id", "title", "description", "device_name","app_version","device_os", "created_at"]) && + request.resource.data.user_id is string && + request.resource.data.title is string && + request.resource.data.description is string && + request.resource.data.device_name is string && + request.resource.data.app_version is string && + request.resource.data.device_os is string && + request.resource.data.created_at is timestamp && + request.resource.data.get('attachments', []) is list; + allow update: if false; + allow delete: if false; + allow read: if isAuthorized() && request.auth.uid == resource.data.user_id; + } - match /users/{docId} { - allow create: if isAuthorized() && request.auth.uid == docId && - request.resource.data.keys().hasAll(["id", "auth_type", "location_enabled", "provider_firebase_id_token", "created_at"]) && - request.resource.data.email is string && - request.resource.data.id is string && - request.resource.data.auth_type is int && - (request.resource.data.auth_type == 1 || request.resource.data.auth_type == 2) && - request.resource.data.location_enabled is bool && - request.resource.data.provider_firebase_id_token is string && - request.resource.data.created_at is int && - request.resource.data.get('first_name', '') is string && - request.resource.data.get('email', '') is string && - request.resource.data.get('last_name', '') is string && - request.resource.data.get('fcm_token', '') is string && - request.resource.data.get('profile_image', '') is string && - request.resource.data.get('space_ids', []) is list; - allow update: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.id; - allow read: if isAuthorized(); - - match /user_locations/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.latitude is number && - request.resource.data.longitude is number && - request.resource.data.created_at is int; - } - - match /user_journeys/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.from_latitude is number && - request.resource.data.from_longitude is number && - request.resource.data.created_at is int; - } - - match /user_sessions/{docId} { - allow read: if isAuthorized(); - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "device_id", "device_name", "platform", "session_active", "app_version", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.device_id is string && - request.resource.data.device_name is string && - request.resource.data.platform is int && - request.resource.data.platform == 1 && - request.resource.data.session_active is bool && - request.resource.data.app_version is int && - request.resource.data.created_at is int; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - } - } + match /users/{docId} { + allow create : if isAuthorized() && request.auth.uid == docId && + request.resource.data.keys().hasAll(["id", "auth_type", "location_enabled", "provider_firebase_id_token", "created_at"]) && + request.resource.data.email is string && + request.resource.data.id is string && + request.resource.data.auth_type is int && + (request.resource.data.auth_type == 1 || request.resource.data.auth_type == 2) && + request.resource.data.location_enabled is bool && + request.resource.data.provider_firebase_id_token is string && + request.resource.data.created_at is int && + request.resource.data.get('first_name', '') is string && + request.resource.data.get('email', '') is string && + request.resource.data.get('last_name', '') is string && + request.resource.data.get('fcm_token', '') is string && + request.resource.data.get('profile_image', '') is string && + request.resource.data.get('space_ids', []) is list; - match /users/{docId}/user_sessions/{document=**} { - allow read: if isAuthorized() && request.auth.uid == docId; - } + allow update: if isAuthorized() && request.auth.uid == resource.data.id && + request.resource.data.diff(resource.data).affectedKeys().hasAny(['first_name', 'last_name', 'profile_image', 'location_enabled', 'space_ids', 'phone', 'email','fcm_token', 'updated_at', 'battery_pct', 'state']) && + request.resource.data.first_name is string && + request.resource.data.get('last_name', '') is string && + request.resource.data.get('fcm_token', '') is string && + request.resource.data.location_enabled is bool && + request.resource.data.get('space_ids', []) is list; - function isSpaceAdmin(spaceId) { - let adminId = get(/databases/$(database)/documents/spaces/$(spaceId)).data.admin_id; - return request.auth.uid == adminId; - } + allow delete: if isAuthorized() && request.auth.uid == resource.data.id; + allow read: if isAuthorized(); - function isSpaceMember(spaceId) { - let isMember = exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); - return isMember; - } + match /user_locations/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.latitude is number && + request.resource.data.longitude is number && + request.resource.data.created_at is int; + } - match /spaces/{docId} { - allow read: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.admin_id; - allow update: if isAuthorized() && request.auth.uid == resource.data.admin_id; - allow create: if isAuthorized() && - request.resource.data.keys().hasAll(["id", "admin_id", "name", "created_at"]) && - request.resource.data.id is string && - request.resource.data.admin_id is string && - request.resource.data.name is string && - request.resource.data.created_at is int; - } + match /user_journeys/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.from_latitude is number && + request.resource.data.from_longitude is number && + request.resource.data.created_at is int; + } - match /{path=**}/space_places/{place} { - allow read: if isAuthorized() && isSpaceMember(resource.data.space_id); - allow write: if false; - } - match /spaces/{spaceId}/space_places/{place} { - allow read: if isAuthorized() && isSpaceMember(spaceId); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.created_by); - allow update: if isAuthorized() && request.auth.uid == resource.data.created_by; - allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.created_by) && - request.resource.data.keys().hasAll(["id", "space_id", "created_by", "latitude", "longitude", "radius", "name", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.created_by is string && - request.resource.data.latitude is number && - request.resource.data.longitude is number && - request.resource.data.radius is number && - request.resource.data.name is string && - request.resource.data.created_at is timestamp; - } + match /user_sessions/{docId} { + allow read: if isAuthorized(); + allow create : if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "device_id", "device_name", "platform", "session_active", "app_version", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.device_id is string && + request.resource.data.device_name is string && + request.resource.data.platform is int && + request.resource.data.platform == 1 && + request.resource.data.session_active is bool && + request.resource.data.app_version is int && + request.resource.data.created_at is int; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + } + } - function isPlaceAdmin(spaceId, place) { - let created_by = get(/databases/$(database)/documents/spaces/$(spaceId)/space_places/$(place)).data.created_by; - return request.auth.uid == created_by; - } + match /users/{docId}/user_sessions/{document=**} { + allow read: if isAuthorized() && request.auth.uid == docId; + } - match /spaces/{spaceId}/space_places/{place}/place_settings_by_members/{member} { - allow read: if isAuthorized() && isSpaceMember(spaceId); - allow update: if isAuthorized() && isSpaceMember(spaceId) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["alert_enable", "leave_alert_for", "arrival_alert_for"]) && - request.resource.data.arrival_alert_for is list && - request.resource.data.leave_alert_for is list && - request.resource.data.alert_enable is bool; - allow delete: if isAuthorized() && (request.auth.uid == resource.data.user_id || isPlaceAdmin(spaceId, place)); - allow create: if isAuthorized() && isSpaceMember(spaceId) && - request.resource.data.keys().hasAll(["user_id", "place_id", "alert_enable", "leave_alert_for", "arrival_alert_for"]) && - request.resource.data.user_id is string && - request.resource.data.place_id is string && - request.resource.data.get('arrival_alert_for', []) is list && - request.resource.data.get('leave_alert_for', []) is list && - request.resource.data.alert_enable is bool; - } + function isSpaceAdmin(spaceId) { + let adminId = get(/databases/$(database)/documents/spaces/$(spaceId)).data.admin_id; + return request.auth.uid == adminId; + } - match /{path=**}/space_members/{member} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(resource.data.space_id)); - allow write: if false; - } + function isSpaceMember(spaceId) { + let isMember = exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); + return isMember; + } - match /spaces/{spaceId}/space_members/{member} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.user_id); - allow update: if isAuthorized() && - (request.auth.uid == resource.data.user_id || isSpaceAdmin(resource.data.space_id)) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["location_enabled", "role"]) && - ((request.resource.data.location_enabled is bool) || - (request.resource.data.role is int && (request.resource.data.role == 1 || request.resource.data.role == 2))); - allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.user_id) && - request.resource.data.keys().hasAll(["id", "space_id", "user_id", "role", "location_enabled", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.user_id is string && - request.resource.data.role is int && - (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/{docId} { + allow read: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.admin_id; + allow update: if isAuthorized() && request.auth.uid == resource.data.admin_id; + allow create: if isAuthorized() && + request.resource.data.keys().hasAll(["id", "admin_id", "name", "created_at"]) && + request.resource.data.id is string && + request.resource.data.admin_id is string && + request.resource.data.name is string && + request.resource.data.created_at is int; - match /spaces/{spaceId}/space_members/{docId} { - match /user_locations/{docId} { - allow read: if true; - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "encrypted_latitude", "encrypted_longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.encrypted_latitude is string && - request.resource.data.encrypted_longitude is string && - request.resource.data.created_at is int; - } - - match /user_journeys/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "encrypted_from_latitude", "encrypted_from_longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.encrypted_from_latitude is string && - request.resource.data.encrypted_from_longitude is string && - request.resource.data.created_at is int; - } - } - match /space_invitations/{docId} { - allow read: if isAuthorized(); - allow delete: if isAuthorized() && isSpaceAdmin(resource.data.space_id); - allow update: if isAuthorized() && isSpaceMember(resource.data.space_id) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["code", "created_at"]) && - request.resource.data.code is string && - request.resource.data.code.size() == 6 && - request.resource.data.created_at is int; - allow create: if isAuthorized() && isSpaceAdmin(request.resource.data.space_id) && - request.resource.data.keys().hasAll(["id", "code", "space_id", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.code is string && - request.resource.data.code.size() == 6 && - request.resource.data.created_at is int; - } + } + match /{path=**}/space_places/{place} { + allow read: if isAuthorized() && isSpaceMember(resource.data.space_id); + allow write: if false; + } - match /space_threads/{docId} { - allow read: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || isSpaceMember(resource.data.space_id)); - allow delete: if isAuthorized() && isThreadAdmin(docId); - allow update: if isAuthorized() && isThreadMember(docId) && - request.resource.data.diff(resource.data).affectedKeys().hasAny(["member_ids", "archived_for"]) && - request.resource.data.get('member_ids', []) is list && - request.resource.data.get('archived_for', {}) is map; - allow create: if isAuthorized() && (isSpaceMember(request.resource.data.space_id)) && - request.resource.data.keys().hasAll(["id", "space_id", "admin_id", "member_ids", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.admin_id is string && - request.resource.data.member_ids is list && - request.resource.data.created_at is int; - } + match /spaces/{spaceId}/space_places/{place} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.created_by); + allow update: if isAuthorized() && request.auth.uid == resource.data.created_by; + allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.created_by) && + request.resource.data.keys().hasAll(["id", "space_id", "created_by", "latitude", "longitude", "radius", "name", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.created_by is string && + request.resource.data.latitude is number && + request.resource.data.longitude is number && + request.resource.data.radius is number && + request.resource.data.name is string && + request.resource.data.created_at is timestamp; + } - function isThreadMember(threadId) { - let memberIds = get(/databases/$(database)/documents/space_threads/$(threadId)).data.member_ids; - return memberIds.hasAny([request.auth.uid]); - } + function isPlaceAdmin(spaceId, place) { + let created_by = get(/databases/$(database)/documents/spaces/$(spaceId)/space_places/$(place)).data.created_by; + return request.auth.uid == created_by; + } - function isThreadAdmin(threadId) { - let adminId = get(/databases/$(database)/documents/space_threads/$(threadId)).data.admin_id; - return adminId == request.auth.uid; - } + match /spaces/{spaceId}/space_places/{place}/place_settings_by_members/{member} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + allow update: if isAuthorized() && isSpaceMember(spaceId) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["alert_enable", "leave_alert_for", "arrival_alert_for"]) && + request.resource.data.arrival_alert_for is list && + request.resource.data.leave_alert_for is list && + request.resource.data.alert_enable is bool; + allow delete: if isAuthorized() && (request.auth.uid == resource.data.user_id || isPlaceAdmin(place)); + allow create: if isAuthorized() && isSpaceMember(spaceId) && + request.resource.data.keys().hasAll(["user_id", "place_id", "alert_enable", "leave_alert_for", "arrival_alert_for"]) && + request.resource.data.user_id is string && + request.resource.data.place_id is string && + request.resource.data.get('arrival_alert_for', []) is list && + request.resource.data.get('leave_alert_for', []) is list && + request.resource.data.alert_enable is bool; + } - match /{path=**}/thread_messages/{docId} { - allow read: if isAuthorized() && isThreadMember(resource.data.thread_id); - } + match /{path=**}/space_members/{member} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(resource.data.space_id)); + allow write: if false; + } + + match /spaces/{spaceId}/space_members/{member} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); + allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.user_id); + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["location_enabled"]) && + request.resource.data.location_enabled is bool; + allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.user_id) && + request.resource.data.keys().hasAll(["id", "space_id", "user_id", "role", "location_enabled", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.user_id is string && + request.resource.data.role is int && + (request.resource.data.role == 1 || request.resource.data.role == 2) && + request.resource.data.location_enabled is bool && + request.resource.data.created_at is int; + + function readSpaceMemberLocation(spaceId) { + return exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); + } + + match /user_locations/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readSpaceMemberLocation()); + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "sender_key", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.latitude is string && + request.resource.data.longitude is string && + request.resource.data.sender_key is string && + request.resource.data.created_at is int; + } + + match /user_journeys/{docId} { + allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readSpaceMemberLocation()); + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.from_latitude is string && + request.resource.data.from_longitude is string && + request.resource.data.created_at is int; + } + } + + match /space_invitations/{docId} { + allow read: if isAuthorized(); + allow delete: if isAuthorized() && isSpaceAdmin(resource.data.space_id); + allow update: if isAuthorized() && isSpaceMember(resource.data.space_id) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["code", "created_at"]) && + request.resource.data.code is string && + request.resource.data.code.size() == 6 && + request.resource.data.created_at is int; + + allow create: if isAuthorized() && isSpaceAdmin(request.resource.data.space_id) && + request.resource.data.keys().hasAll(["id", "code", "space_id", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.code is string && + request.resource.data.code.size() == 6 && + request.resource.data.created_at is int; + } + + + match /space_threads/{docId} { + allow read: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || isSpaceMember(resource.data.space_id)); + allow delete: if isAuthorized() && isThreadAdmin(docId); + allow update: if isAuthorized() && isThreadMember(docId) && + request.resource.data.diff(resource.data).affectedKeys().hasAny(["member_ids", "archived_for"]) && + request.resource.data.get('member_ids', []) is list && + request.resource.data.get('archived_for', {}) is map; + allow create: if isAuthorized() && (isSpaceMember(request.resource.data.space_id)) && + request.resource.data.keys().hasAll(["id", "space_id", "admin_id", "member_ids", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.admin_id is string && + request.resource.data.member_ids is list && + request.resource.data.created_at is int; + + } + function isThreadMember(threadId) { + let memberIds = get(/databases/$(database)/documents/space_threads/$(threadId)).data.member_ids; + return memberIds.hasAny([request.auth.uid]); + } + + function isThreadAdmin(threadId) { + let adminId = get(/databases/$(database)/documents/space_threads/$(threadId)).data.admin_id; + return adminId == request.auth.uid; + } + + match /{path=**}/thread_messages/{docId} { + allow read: if isAuthorized() && isThreadMember(resource.data.thread_id); + } - match /space_threads/{threadId}/thread_messages/{docId} { - allow read: if isAuthorized() && isThreadMember(threadId); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id)); - allow update: if isAuthorized() && isThreadMember(threadId) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["seen_by"]) && - request.resource.data.seen_by is list; - allow create: if isAuthorized() && isThreadMember(threadId) && request.resource.data.sender_id == request.auth.uid && - request.resource.data.keys().hasAll(["id", "thread_id", "sender_id", "message", "seen_by", "created_at"]) && - request.resource.data.id is string && - request.resource.data.thread_id is string && - request.resource.data.sender_id is string && - request.resource.data.message is string && - request.resource.data.seen_by is list && - request.resource.data.created_at is timestamp; + match /space_threads/{threadId}/thread_messages/{docId} { + allow read: if isAuthorized() && isThreadMember(threadId); + allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id)); + allow update: if isAuthorized() && isThreadMember(threadId) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["seen_by"]) && + request.resource.data.seen_by is list; + allow create: if isAuthorized() && isThreadMember(threadId) && request.resource.data.sender_id == request.auth.uid && + request.resource.data.keys().hasAll(["id", "thread_id", "sender_id", "message", "seen_by", "created_at"]) && + request.resource.data.id is string && + request.resource.data.thread_id is string && + request.resource.data.sender_id is string && + request.resource.data.message is string && + request.resource.data.seen_by is list && + request.resource.data.created_at is timestamp; + } } - } -} +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 957f8ef9..2cbd6d19 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,4 +21,3 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true -android.enableR8.fullMode=false From ab61d8cc8fd6740028fb9999a5a0131600ad652c Mon Sep 17 00:00:00 2001 From: cp-megh Date: Wed, 25 Dec 2024 13:13:33 +0530 Subject: [PATCH 05/30] WIP --- app/build.gradle.kts | 9 +- build.gradle.kts | 1 - data/build.gradle.kts | 40 +- .../yourspace/data/di/AppDataProvider.kt | 4 + .../data/models/location/ApiLocation.kt | 5 +- .../data/models/location/LocationJourney.kt | 1 - .../yourspace/data/models/space/ApiSpace.kt | 31 + .../yourspace/data/models/user/ApiUser.kt | 5 +- .../data/repository/SpaceRepository.kt | 2 +- .../data/service/auth/AuthService.kt | 2 +- .../service/location/ApiJourneyService.kt | 22 +- .../service/location/ApiLocationService.kt | 146 ++++- .../data/service/space/ApiSpaceService.kt | 100 +++- .../data/service/user/ApiUserService.kt | 15 +- .../yourspace/data/storage/UserPreferences.kt | 23 +- .../canopas/yourspace/data/utils/Config.kt | 1 + .../yourspace/data/utils/EncryptionUtils.kt | 251 ++++++++ .../canopas/yourspace/data/utils/KeyHelper.kt | 43 ++ firestore.rules | 551 +++++++++--------- gradle.properties | 1 + 20 files changed, 898 insertions(+), 355 deletions(-) create mode 100644 data/src/main/java/com/canopas/yourspace/data/utils/EncryptionUtils.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7aaae700..202f11be 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,6 @@ android { defaultConfig { applicationId = "com.canopas.yourspace" minSdk = 24 - targetSdk = 34 versionCode = versionMajor * 1000000 + versionMinor * 10000 + versionBuild versionName = "$versionMajor.$versionMinor.$versionBuild" setProperty("archivesBaseName", "GroupTrack-$versionName-$versionCode") @@ -103,12 +102,12 @@ android { } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } buildFeatures { compose = true @@ -208,7 +207,7 @@ dependencies { implementation("androidx.core:core-splashscreen:1.0.1") // Desugaring - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.3") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") implementation(project(":data")) } diff --git a/build.gradle.kts b/build.gradle.kts index f4d20266..6161aafc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,5 +9,4 @@ plugins { id("com.google.firebase.crashlytics") version "3.0.2" apply false id("org.jetbrains.kotlin.jvm") version "1.9.23" id("com.google.devtools.ksp") version "1.9.23-1.0.20" - id("com.google.protobuf") version "0.9.4" apply false } diff --git a/data/build.gradle.kts b/data/build.gradle.kts index f7203dc2..56c50496 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -4,7 +4,6 @@ plugins { id("com.google.dagger.hilt.android") id("org.jlleitschuh.gradle.ktlint") id("com.google.devtools.ksp") - id("com.google.protobuf") } android { @@ -28,19 +27,16 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } kotlinOptions { - jvmTarget = "1.8" + jvmTarget = "17" } ktlint { debug = true } - configurations.all { - resolutionStrategy.force("com.google.protobuf:protobuf-javalite:3.10.0") - } } dependencies { @@ -48,6 +44,8 @@ dependencies { implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.appcompat:appcompat:1.7.0") implementation("com.google.android.material:material:1.12.0") + implementation("androidx.hilt:hilt-common:1.2.0") + implementation("androidx.work:work-runtime:2.9.1") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") @@ -83,33 +81,17 @@ dependencies { implementation("androidx.room:room-ktx:2.6.1") // Desugaring - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.3") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") // Place implementation("com.google.android.libraries.places:places:4.0.0") // Signal Protocol - implementation("org.whispersystems:signal-protocol-android:2.8.1") { - exclude(group = "com.google.protobuf", module = "protolite-java") - } - implementation("com.google.protobuf:protobuf-javalite:3.10.0") // Align with Signal Protocol version + implementation("org.signal:libsignal-client:0.64.1") + implementation("org.signal:libsignal-android:0.64.1") - // AndroidX Security for EncryptedSharedPreferences - implementation("androidx.security:security-crypto:1.1.0-alpha06") - -} + // Bouncy-castle for Signal Protocol + implementation("org.bouncycastle:bcprov-jdk15on:1.70") -protobuf { - protoc { - artifact = "com.google.protobuf:protoc:3.10.0" - } - generateProtoTasks { - all().forEach { task -> - task.builtins { - create("java") { - option("lite") - } - } - } - } + implementation("androidx.security:security-crypto:1.1.0-alpha03") } diff --git a/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt b/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt index 95689667..52ea0252 100644 --- a/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt +++ b/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt @@ -51,4 +51,8 @@ class AppDataProvider { @Provides @Singleton fun providedApiUserSession(): ApiUserSession = ApiUserSession() + + @Provides + @Singleton + fun provideAppContext(@ApplicationContext context: Context): Context = context } diff --git a/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt b/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt index 04d34497..e0bfc4af 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt @@ -1,6 +1,7 @@ package com.canopas.yourspace.data.models.location import androidx.annotation.Keep +import com.google.firebase.firestore.Blob import com.squareup.moshi.JsonClass import java.util.UUID @@ -19,8 +20,8 @@ data class ApiLocation( data class EncryptedApiLocation( val id: String = UUID.randomUUID().toString(), val user_id: String = "", - val encrypted_latitude: String = "", // Base64 encoded encrypted latitude - val encrypted_longitude: String = "", // Base64 encoded encrypted longitude + val encrypted_latitude: Blob = Blob.fromBytes(ByteArray(0)), // Base64 encoded encrypted latitude + val encrypted_longitude: Blob = Blob.fromBytes(ByteArray(0)), // Base64 encoded encrypted longitude val created_at: Long? = System.currentTimeMillis() ) 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 b3b27907..d2b9e617 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 @@ -38,7 +38,6 @@ data class EncryptedLocationJourney( val updated_at: Long? = System.currentTimeMillis() ) - @Keep data class JourneyRoute(val latitude: Double = 0.0, val longitude: Double = 0.0) 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 0d83f35b..21080ec8 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 @@ -1,6 +1,7 @@ package com.canopas.yourspace.data.models.space import androidx.annotation.Keep +import com.google.firebase.firestore.Blob import com.google.firebase.firestore.Exclude import java.util.UUID import java.util.concurrent.TimeUnit @@ -23,6 +24,7 @@ data class ApiSpaceMember( val user_id: String = "", val role: Int = SPACE_MEMBER_ROLE_MEMBER, val location_enabled: Boolean = true, + val identity_key_public: Blob? = Blob.fromBytes(ByteArray(0)), val created_at: Long? = System.currentTimeMillis() ) @@ -45,3 +47,32 @@ data class ApiSpaceInvitation( return differenceMillis > twoDaysMillis } } + +/** + * Data class that represents the entire "groupKeys/{senderUserId}" doc + * in Firestore for a single sender's key distribution. + */ +data class SenderKeyDistributionDoc( + val senderId: String = "", + val distributions: List = emptyList(), + val createdAt: Long = 0 +) + +/** + * Represents one encrypted distribution for a specific recipient. + * Each recipient gets their own ciphertext, which is the group SenderKeyDistributionMessage + * encrypted with the recipient's public key. + */ +data class EncryptedDistribution( + val recipientId: String, + val deviceId: Blob, + val ciphertext: Blob +) { + fun toMap(): Map { + return mapOf( + "recipientId" to recipientId, + "deviceId" to deviceId, + "ciphertext" to ciphertext + ) + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt index e35e5e98..9a617d4b 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt @@ -1,6 +1,7 @@ package com.canopas.yourspace.data.models.user import androidx.annotation.Keep +import com.google.firebase.firestore.Blob import com.google.firebase.firestore.Exclude import com.squareup.moshi.JsonClass import java.util.UUID @@ -24,7 +25,9 @@ data class ApiUser( val state: Int = USER_STATE_UNKNOWN, val battery_pct: Float? = 0f, val created_at: Long? = System.currentTimeMillis(), - val updated_at: Long? = System.currentTimeMillis() + val updated_at: Long? = System.currentTimeMillis(), + val identity_key_public: Blob? = Blob.fromBytes(ByteArray(0)), + val identity_key_private: Blob? = Blob.fromBytes(ByteArray(0)) ) { @get:Exclude val fullName: String get() = "$first_name $last_name" 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 bfcc43b4..05c798f7 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 @@ -152,7 +152,7 @@ class SpaceRepository @Inject constructor( val session = userService.getUserSession(member.user_id) user?.let { locationService.getCurrentLocation(user.id) - ?.map { + .map { UserInfo( user, it.firstOrNull(), 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 311e74b5..0923b2e2 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 @@ -75,7 +75,7 @@ class AuthService @Inject constructor( userPreferences.currentUser = newUser } - private var currentUserSession: ApiUserSession? + var currentUserSession: ApiUserSession? get() { return userPreferences.currentUserSession } 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 71050bc8..14a6f146 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 @@ -9,7 +9,6 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBER import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.toObject -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.tasks.await import timber.log.Timber import javax.inject.Inject @@ -32,9 +31,9 @@ class ApiJourneyService @Inject constructor( FIRESTORE_COLLECTION_SPACE_MEMBERS ) - private fun spaceMemberJourneyRef(spaceId: String) = + private fun spaceMemberJourneyRef(spaceId: String, userId: String) = spaceMemberRef(spaceId) - .document(currentSpaceId.takeIf { it.isNotBlank() } ?: "null") + .document(userId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_JOURNEYS) suspend fun saveCurrentJourney( @@ -51,7 +50,7 @@ class ApiJourneyService @Inject constructor( newJourneyId: ((String) -> Unit)? = null ) { userPreferences.currentUser?.space_ids?.forEach { - val docRef = spaceMemberJourneyRef(it).document() + val docRef = spaceMemberJourneyRef(it, userId).document() val journey = LocationJourney( id = docRef.id, @@ -76,7 +75,7 @@ class ApiJourneyService @Inject constructor( suspend fun updateLastLocationJourney(userId: String, journey: LocationJourney) { try { userPreferences.currentUser?.space_ids?.forEach { - spaceMemberJourneyRef(it).document(journey.id).set(journey).await() + spaceMemberJourneyRef(it, userId).document(journey.id).set(journey).await() } } catch (e: Exception) { Timber.e(e, "Error while updating last location journey") @@ -84,7 +83,7 @@ class ApiJourneyService @Inject constructor( } suspend fun getLastJourneyLocation(userId: String) = try { - spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING).limit(1) .get().await().documents.firstOrNull()?.toObject() } catch (e: Exception) { @@ -97,11 +96,11 @@ class ApiJourneyService @Inject constructor( from: Long? ): List { val query = if (from == null) { - spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) .limit(20) } else { - spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) .whereLessThan("created_at", from) .limit(20) @@ -114,13 +113,13 @@ class ApiJourneyService @Inject constructor( from: Long, to: Long ): List { - val previousDayJourney = spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + val previousDayJourney = spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .whereLessThan("created_at", from) .whereGreaterThanOrEqualTo("update_at", from) .limit(1) .get().await().documents.mapNotNull { it.toObject() } - val currentDayJourney = spaceMemberJourneyRef(currentSpaceId).whereEqualTo("user_id", userId) + val currentDayJourney = spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .whereGreaterThanOrEqualTo("created_at", from) .whereLessThanOrEqualTo("created_at", to) .orderBy("created_at", Query.Direction.DESCENDING) @@ -131,7 +130,8 @@ class ApiJourneyService @Inject constructor( } suspend fun getLocationJourneyFromId(journeyId: String): LocationJourney? { - return spaceMemberJourneyRef(currentSpaceId).document(journeyId).get().await() + val userId = userPreferences.currentUser?.id ?: return null + return spaceMemberJourneyRef(currentSpaceId, userId).document(journeyId).get().await() .toObject(LocationJourney::class.java) } } 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 1c8cd874..5b49cdcc 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 @@ -1,16 +1,25 @@ package com.canopas.yourspace.data.service.location -import android.util.Log import com.canopas.yourspace.data.models.location.ApiLocation +import com.canopas.yourspace.data.models.location.EncryptedApiLocation import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES 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.Blob import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.ecc.ECPrivateKey +import org.signal.libsignal.protocol.groups.GroupCipher +import org.signal.libsignal.protocol.groups.GroupSessionBuilder +import org.signal.libsignal.protocol.groups.state.InMemorySenderKeyStore +import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -28,23 +37,42 @@ class ApiLocationService @Inject constructor( spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( FIRESTORE_COLLECTION_SPACE_MEMBERS ) - private fun spaceMemberLocationRef(spaceId: String) = + + private fun spaceMemberLocationRef(spaceId: String, userId: String) = spaceMemberRef(spaceId) - .document(currentSpaceId.takeIf { it.isNotBlank() } ?: "null") + .document(userId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_LOCATIONS) + private fun spaceGroupKeysRef(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( + Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS + ) + suspend fun saveLastKnownLocation( userId: String ) { val lastLocation = locationManager.getLastLocation() ?: return - userPreferences.currentUser?.space_ids?.forEach { - val docRef = spaceMemberLocationRef(it).document() + userPreferences.currentUser?.space_ids?.forEach { spaceId -> + val cipherAndDistributionMessage = getGroupCipherAndDistributionMessage(spaceId, userId) + val groupCipher = cipherAndDistributionMessage?.second ?: return + val distributionMessage = cipherAndDistributionMessage.first + val lat = groupCipher.encrypt( + distributionMessage.distributionId, + lastLocation.latitude.toString().toByteArray(Charsets.UTF_8) + ) + val lon = groupCipher.encrypt( + distributionMessage.distributionId, + lastLocation.longitude.toString().toByteArray(Charsets.UTF_8) + ) + + Timber.d("Last known location: $lastLocation\nLat: $lat\nLon: $lon") + val docRef = spaceMemberLocationRef(spaceId, userId).document() - val location = ApiLocation( + val location = EncryptedApiLocation( id = docRef.id, user_id = userId, - latitude = lastLocation.latitude, - longitude = lastLocation.longitude, + encrypted_latitude = Blob.fromBytes(lat.serialize()), + encrypted_longitude = Blob.fromBytes(lon.serialize()), created_at = System.currentTimeMillis() ) @@ -58,15 +86,30 @@ class ApiLocationService @Inject constructor( longitude: Double, recordedAt: Long ) { - Log.e("XXXXXX", "SpaceId: $currentSpaceId") - userPreferences.currentUser?.space_ids?.forEach { - val docRef = spaceMemberLocationRef(it).document() + Timber.e("Saving current location for user $userId") + userPreferences.currentUser?.space_ids?.forEach { spaceId -> + val cipherAndDistributionMessage = getGroupCipherAndDistributionMessage(spaceId, userId) + Timber.e("Cipher and distribution message: $cipherAndDistributionMessage") + val groupCipher = cipherAndDistributionMessage?.second ?: return + val distributionMessage = cipherAndDistributionMessage.first + val lat = groupCipher.encrypt( + distributionMessage.distributionId, + latitude.toString().toByteArray(Charsets.UTF_8) + ) + val lon = groupCipher.encrypt( + distributionMessage.distributionId, + longitude.toString().toByteArray(Charsets.UTF_8) + ) - val location = ApiLocation( + Timber.d("Current location: $latitude, $longitude\nLat: $lat\nLon: $lon") + + val docRef = spaceMemberLocationRef(spaceId, userId).document() + + val location = EncryptedApiLocation( id = docRef.id, user_id = userId, - latitude = latitude, - longitude = longitude, + encrypted_latitude = Blob.fromBytes(lat.serialize()), + encrypted_longitude = Blob.fromBytes(lon.serialize()), created_at = recordedAt ) @@ -74,14 +117,73 @@ class ApiLocationService @Inject constructor( } } - fun getCurrentLocation(userId: String): Flow>? { - return try { - spaceMemberLocationRef(currentSpaceId).whereEqualTo("user_id", userId) - .orderBy("created_at", Query.Direction.DESCENDING).limit(1) - .snapshotFlow(ApiLocation::class.java) - } catch (e: Exception) { - Timber.e(e, "Error while getting current location") - null + suspend fun getCurrentLocation(userId: String): Flow> { + return flow { + try { + val encryptedLocation = + spaceMemberLocationRef(currentSpaceId, userId).whereEqualTo("user_id", userId) + .orderBy("created_at", Query.Direction.DESCENDING).limit(1) + .snapshotFlow(EncryptedApiLocation::class.java) + encryptedLocation.collect { encryptedLocationList -> + val apiLocations = encryptedLocationList.map { encryptedLocation -> + val receiverGroupCipher = + getGroupCipherAndDistributionMessage(currentSpaceId, userId)?.second + ?: return@map null + Timber.e("Receiver group cipher: $receiverGroupCipher") + val lat = + receiverGroupCipher.decrypt(encryptedLocation.encrypted_latitude.toBytes()) + val lon = + receiverGroupCipher.decrypt(encryptedLocation.encrypted_longitude.toBytes()) + + Timber.d("Decrypted location: $lat, $lon") + + ApiLocation( + user_id = userId, + latitude = lat.toString(Charsets.UTF_8).toDouble(), + longitude = lon.toString(Charsets.UTF_8).toDouble() + ) + } + emit(apiLocations) + } + } catch (e: Exception) { + Timber.e(e, "Error while getting current location") + } } } + + private suspend fun getGroupCipherAndDistributionMessage( + spaceId: String, + userId: String + ): Pair? { + val currentUser = userPreferences.currentUser ?: return null + Timber.e("Getting group cipher for space $spaceId\tUser: $userId") + val sharedDistributionMessage = spaceGroupKeysRef(spaceId) + .document(userId).get().await() + val distributions = + sharedDistributionMessage["distributions"] as? List> ?: emptyList() + val currentUserDistribution = + distributions.firstOrNull { it["recipientId"] == currentUser.id } ?: return null + val currentUserCiphertext = + (currentUserDistribution["ciphertext"] as? Blob)?.toBytes() ?: ByteArray(0) + val deviceIdBytes = + (currentUserDistribution["deviceId"] as? Blob)?.toBytes() ?: ByteArray(0) + val currentUserPrivateKey = + ECPrivateKey(currentUser.identity_key_private?.toBytes() ?: ByteArray(0)) + val distributionBytes = + EphemeralECDHUtils.decrypt(currentUserCiphertext, currentUserPrivateKey) + val deviceId = EphemeralECDHUtils.decrypt(deviceIdBytes, currentUserPrivateKey) + val distributionMessage = SenderKeyDistributionMessage(distributionBytes) + val receiverKeyStore = InMemorySenderKeyStore() + val senderAddress = SignalProtocolAddress( + spaceId, + deviceId.toString(Charsets.UTF_8).toInt() + ) + val sessionBuilder = GroupSessionBuilder(receiverKeyStore) + val receiverGroupCipher = GroupCipher(receiverKeyStore, senderAddress) + sessionBuilder.process(senderAddress, distributionMessage) + Timber.e("Group cipher created for space $spaceId") + // Log everything created here + Timber.e("Distribution message: $distributionMessage\nGroup cipher: $receiverGroupCipher") + return Pair(distributionMessage, receiverGroupCipher) + } } 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 cb9cdf3d..f49b1b94 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 @@ -2,16 +2,28 @@ package com.canopas.yourspace.data.service.space import com.canopas.yourspace.data.models.space.ApiSpace import com.canopas.yourspace.data.models.space.ApiSpaceMember +import com.canopas.yourspace.data.models.space.EncryptedDistribution import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_ADMIN import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_MEMBER +import com.canopas.yourspace.data.models.space.SenderKeyDistributionDoc import com.canopas.yourspace.data.service.auth.AuthService import com.canopas.yourspace.data.service.place.ApiPlaceService import com.canopas.yourspace.data.service.user.ApiUserService +import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS 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.Blob import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.protocol.groups.GroupSessionBuilder +import org.signal.libsignal.protocol.groups.state.InMemorySenderKeyStore +import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -20,36 +32,101 @@ class ApiSpaceService @Inject constructor( private val db: FirebaseFirestore, private val authService: AuthService, private val apiUserService: ApiUserService, - private val placeService: ApiPlaceService + private val placeService: ApiPlaceService, + private val userPreferences: UserPreferences ) { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) - internal fun spaceMemberRef(spaceId: String) = - spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) + private fun spaceMemberRef(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") + .collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) + + private fun spaceGroupKeysRef(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( + FIRESTORE_COLLECTION_SPACE_GROUP_KEYS + ) suspend fun createSpace(spaceName: String): String { - val docRef = spaceRef.document() - val spaceId = docRef.id + val spaceId = UUID.randomUUID().toString() + val docRef = spaceRef.document(spaceId) val userId = authService.currentUser?.id ?: "" - val space = ApiSpace(id = spaceId, name = spaceName, admin_id = userId) + + val space = ApiSpace( + id = spaceId, + name = spaceName, + admin_id = userId + ) docRef.set(space).await() joinSpace(spaceId, SPACE_MEMBER_ROLE_ADMIN) return spaceId } + private suspend fun getSpaceMembers(spaceId: String): List { + return spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) + } + suspend fun joinSpace(spaceId: String, role: Int = SPACE_MEMBER_ROLE_MEMBER) { - val userId = authService.currentUser?.id ?: "" + val user = authService.currentUser ?: return + Timber.e("SpaceId: $spaceId, UserId: ${user.id}") spaceMemberRef(spaceId) - .document(userId).also { + .document(user.id).also { val member = ApiSpaceMember( space_id = spaceId, - user_id = userId, + user_id = user.id, role = role, + identity_key_public = user.identity_key_public, location_enabled = true ) it.set(member).await() } - apiUserService.addSpaceId(userId, spaceId) + apiUserService.addSpaceId(user.id, spaceId) + + // Distribute sender key to all members + distributeSenderKeyToSpaceMembers(spaceId, user.id) + } + + /** + * Create a sender key distribution for the current/joining user, and encrypt that distribution + * for each member. + **/ + private suspend fun distributeSenderKeyToSpaceMembers(spaceId: String, senderUserId: String) { + val deviceId = userPreferences.currentUserSession?.device_id ?: "" + val deviceIdInt = deviceId.hashCode() and 0x7FFFFFFF + val groupAddress = SignalProtocolAddress(spaceId, deviceIdInt) + val senderKeyStore = InMemorySenderKeyStore() + val sessionBuilder = GroupSessionBuilder(senderKeyStore) + val distributionMessage = sessionBuilder.create(groupAddress, UUID.fromString(spaceId)) + val distributionBytes = distributionMessage.serialize() + + val apiSpaceMembers = getSpaceMembers(spaceId) + val distributions = mutableListOf() + + for (member in apiSpaceMembers) { + val publicBlob = member.identity_key_public ?: continue + val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) + + // Encrypt distribution using member's public key + val encBytes = EphemeralECDHUtils.encrypt(distributionBytes, publicKey) + val encDeviceId = EphemeralECDHUtils.encrypt(deviceIdInt.toString().toByteArray(), publicKey) + distributions.add( + EncryptedDistribution( + recipientId = member.user_id, + deviceId = Blob.fromBytes(encDeviceId), + ciphertext = Blob.fromBytes(encBytes) + ) + ) + } + + val docRef = spaceGroupKeysRef(spaceId).document(senderUserId) + + val data = SenderKeyDistributionDoc( + senderId = senderUserId, + distributions = distributions, + createdAt = System.currentTimeMillis() + ) + + docRef.set(data).await() + Timber.d("Sender key distribution uploaded for $senderUserId in space $spaceId.") } suspend fun enableLocation(spaceId: String, userId: String, enable: Boolean) { @@ -69,7 +146,8 @@ class ApiSpaceService @Inject constructor( suspend fun getSpace(spaceId: String) = spaceRef.document(spaceId).get().await().toObject(ApiSpace::class.java) - fun getSpaceFlow(spaceId: String) = spaceRef.document(spaceId).snapshotFlow(ApiSpace::class.java) + fun getSpaceFlow(spaceId: String) = + spaceRef.document(spaceId).snapshotFlow(ApiSpace::class.java) fun getSpaceMemberByUserId(userId: String) = db.collectionGroup(FIRESTORE_COLLECTION_SPACE_MEMBERS).whereEqualTo("user_id", userId) 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 9f52f4ab..b8e525e1 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 @@ -1,5 +1,6 @@ package com.canopas.yourspace.data.service.user +import android.content.Context import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.models.user.ApiUserSession import com.canopas.yourspace.data.models.user.LOGIN_TYPE_APPLE @@ -8,9 +9,11 @@ import com.canopas.yourspace.data.service.location.ApiLocationService 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.KeyHelper import com.canopas.yourspace.data.utils.snapshotFlow import com.google.android.gms.auth.api.signin.GoogleSignInAccount 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.functions.FirebaseFunctions @@ -29,7 +32,8 @@ class ApiUserService @Inject constructor( db: FirebaseFirestore, private val device: Device, private val locationService: ApiLocationService, - private val functions: FirebaseFunctions + private val functions: FirebaseFunctions, + val context: Context ) { private val userRef = db.collection(FIRESTORE_COLLECTION_USERS) private fun sessionRef(userId: String) = @@ -45,10 +49,10 @@ class ApiUserService @Inject constructor( } } - suspend fun getUserFlow(userId: String) = + fun getUserFlow(userId: String) = userRef.document(userId).snapshotFlow(ApiUser::class.java) - suspend fun getUserSessionFlow(userId: String, sessionId: String) = + fun getUserSessionFlow(userId: String, sessionId: String) = sessionRef(userId).document(sessionId).snapshotFlow(ApiUserSession::class.java) suspend fun saveUser( @@ -74,6 +78,7 @@ class ApiUserService @Inject constructor( sessionDocRef.set(session).await() return Triple(false, savedUser, session) } else { + val identityKeyPair = KeyHelper.generateIdentityKeyPair() val user = ApiUser( id = uid!!, email = account?.email ?: firebaseUser?.email ?: "", @@ -82,7 +87,9 @@ class ApiUserService @Inject constructor( last_name = account?.familyName ?: "", provider_firebase_id_token = firebaseToken, profile_image = account?.photoUrl?.toString() ?: firebaseUser?.photoUrl?.toString() - ?: "" + ?: "", + identity_key_public = Blob.fromBytes(identityKeyPair.publicKey.serialize()), + identity_key_private = Blob.fromBytes(identityKeyPair.privateKey.serialize()) ) userRef.document(uid).set(user).await() val sessionDocRef = sessionRef(user.id).document() diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt b/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt index 108ef7f0..623c8711 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt @@ -1,5 +1,6 @@ package com.canopas.yourspace.data.storage +import android.util.Base64 import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey @@ -10,8 +11,11 @@ import com.canopas.yourspace.data.models.user.ApiUserSession import com.canopas.yourspace.data.storage.UserPreferences.PreferencesKey.KEY_USER_CURRENT_SPACE import com.canopas.yourspace.data.storage.UserPreferences.PreferencesKey.KEY_USER_JSON import com.canopas.yourspace.data.storage.UserPreferences.PreferencesKey.KEY_USER_SESSION_JSON +import com.google.firebase.firestore.Blob +import com.squareup.moshi.FromJson import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi +import com.squareup.moshi.ToJson import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -26,9 +30,9 @@ const val PREF_USER_PREFERENCES = "your_space_user_preferences" class UserPreferences @Inject constructor( @Named(PREF_USER_PREFERENCES) private val preferencesDataStore: DataStore ) { - private val userJsonAdapter: JsonAdapter = - Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build().adapter(ApiUser::class.java) + Moshi.Builder().add(BlobTypeAdapter()).addLast(KotlinJsonAdapterFactory()).build() + .adapter(ApiUser::class.java) private val userSessionJsonAdapter: JsonAdapter = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() .adapter(ApiUserSession::class.java) @@ -166,3 +170,18 @@ class UserPreferences @Inject constructor( } } } + +class BlobTypeAdapter { + @ToJson + fun toJson(blob: Blob?): String? { + return blob?.let { Base64.encodeToString(it.toBytes(), Base64.DEFAULT) } + } + + @FromJson + fun fromJson(base64: String?): Blob? { + return base64?.let { + val bytes = Base64.decode(it, Base64.DEFAULT) + Blob.fromBytes(bytes) + } + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/Config.kt b/data/src/main/java/com/canopas/yourspace/data/utils/Config.kt index 12b259e6..7dcc5060 100644 --- a/data/src/main/java/com/canopas/yourspace/data/utils/Config.kt +++ b/data/src/main/java/com/canopas/yourspace/data/utils/Config.kt @@ -10,6 +10,7 @@ object Config { const val FIRESTORE_COLLECTION_SPACE_MEMBERS = "space_members" const val FIRESTORE_COLLECTION_SPACE_PLACES = "space_places" const val FIRESTORE_COLLECTION_SPACE_PLACES_MEMBER_SETTINGS = "place_settings_by_members" + const val FIRESTORE_COLLECTION_SPACE_GROUP_KEYS = "group_keys" const val FIRESTORE_COLLECTION_SPACE_INVITATION = "space_invitations" const val FIRESTORE_COLLECTION_SPACE_THREADS = "space_threads" diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/EncryptionUtils.kt b/data/src/main/java/com/canopas/yourspace/data/utils/EncryptionUtils.kt new file mode 100644 index 00000000..895448c5 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/utils/EncryptionUtils.kt @@ -0,0 +1,251 @@ +package com.canopas.yourspace.data.utils + +import org.bouncycastle.crypto.InvalidCipherTextException +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.protocol.ecc.ECPrivateKey +import org.signal.libsignal.protocol.ecc.ECPublicKey +import org.signal.libsignal.protocol.kdf.HKDF +import timber.log.Timber +import java.nio.ByteBuffer +import java.security.PrivateKey +import java.security.PublicKey +import java.security.SecureRandom +import java.security.Security +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +object EncryptionUtils { + + init { + Security.addProvider(BouncyCastleProvider()) + } + + fun encryptWithPublicKey(publicKey: ByteArray, data: ByteArray): ByteArray { + val keyFactory = java.security.KeyFactory.getInstance("RSA", "BC") + val publicKeySpec = java.security.spec.X509EncodedKeySpec(publicKey) + val publicKeyObj: PublicKey = keyFactory.generatePublic(publicKeySpec) + + val cipher = try { + Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding", "BC") + } catch (e: Exception) { + throw RuntimeException("Failed to initialize cipher", e) + } + + cipher.init(Cipher.ENCRYPT_MODE, publicKeyObj) + + return cipher.doFinal(data) + } + + fun decryptWithPrivateKey(privateKey: ByteArray, encryptedData: ByteArray): ByteArray { + val keyFactory = java.security.KeyFactory.getInstance("RSA", "BC") + val privateKeySpec = java.security.spec.PKCS8EncodedKeySpec(privateKey) + val privateKeyObj: PrivateKey = keyFactory.generatePrivate(privateKeySpec) + + val cipher = try { + Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding", "BC") + } catch (e: Exception) { + throw RuntimeException("Failed to initialize cipher", e) + } + + cipher.init(Cipher.DECRYPT_MODE, privateKeyObj) + + return cipher.doFinal(encryptedData) + } +} + +/** + * A production-ready example of ephemeral ECDH encryption ("ECIES") with: + * - Curve25519 for ECDH, + * - HKDF for key derivation, + * - AES/GCM for authenticated encryption. + * + * Data layout = [ ephemeralPub(32|33 bytes) || iv(12 bytes) || ciphertext+authTag(...) ]. + */ +object CryptoUtils { + + private const val KEY_LENGTH_BYTES = 32 // 256-bit AES key + private const val IV_LENGTH_BYTES = 12 // 96-bit GCM IV + private const val GCM_TAG_BITS = 128 + + private val secureRandom = SecureRandom() + + /** + * Encrypts [plaintext] for [recipientPub] using ephemeral ECDH -> HKDF -> AES/GCM. + * Returns a byte array containing: + * ephemeralPub + iv + AES-GCM ciphertext (which includes auth tag). + */ + fun encryptForPublicKey(plaintext: ByteArray, recipientPub: ECPublicKey): ByteArray { + // 1) Generate ephemeral key pair + val ephemeralPair = Curve.generateKeyPair() // Curve25519 + val ephemeralPubBytes = ephemeralPair.publicKey.serialize() // 32 or 33 bytes + + // 2) Compute ECDH shared secret + val sharedSecret = Curve.calculateAgreement(recipientPub, ephemeralPair.privateKey) + // 3) Derive AES key with HKDF + val derivedAesKey = hkdfDeriveKey(sharedSecret, "ECDH-Encryption") + + // 4) AES/GCM encrypt + val iv = ByteArray(IV_LENGTH_BYTES).also { secureRandom.nextBytes(it) } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(derivedAesKey, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) + val cipherText = cipher.doFinal(plaintext) + + // 5) Build final output: ephemeralPub + iv + ciphertext + return ByteBuffer.allocate(ephemeralPubBytes.size + iv.size + cipherText.size) + .put(ephemeralPubBytes) + .put(iv) + .put(cipherText) + .array() + } + + /** + * Decrypts [ciphertext] with our Curve25519 [myPrivate] key. + * Expects the layout: ephemeralPub(32|33) + iv(12) + actualCiphertext. + */ + fun decryptWithPrivateKey(ciphertext: ByteArray, myPrivate: ECPrivateKey): ByteArray { + // 1) Parse ephemeralPub from the front + // Typically 32 bytes for Curve25519. Some libs produce 33. Check your library's format. + val ephemeralPubSize = getEphemeralPubLength(ciphertext) + val ephemeralPubBytes = ciphertext.sliceArray(0 until ephemeralPubSize) + val ephemeralPub = Curve.decodePoint(ephemeralPubBytes, 0) as ECPublicKey + + // 2) Next 12 bytes = IV + val ivStart = ephemeralPubSize + val ivEnd = ivStart + IV_LENGTH_BYTES + val iv = ciphertext.sliceArray(ivStart until ivEnd) + + // 3) The rest is the GCM ciphertext+authTag + val actualCipherBytes = ciphertext.sliceArray(ivEnd until ciphertext.size) + + // 4) ECDH + val sharedSecret = Curve.calculateAgreement(ephemeralPub, myPrivate) + val derivedAesKey = hkdfDeriveKey(sharedSecret, "ECDH-Encryption") + + // 5) AES/GCM decrypt + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(derivedAesKey, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(actualCipherBytes) + } + + /** + * Derive a 32-byte key from [sharedSecret] + optional [info] using HKDF (SHA-256). + */ + private fun hkdfDeriveKey(sharedSecret: ByteArray, info: String): ByteArray { + // salt = null => effectively empty + // We use HKDFv3 from libsignal-protocol + return HKDF.deriveSecrets( + sharedSecret, + ByteArray(0), // no salt + info.toByteArray(), // info context + KEY_LENGTH_BYTES // 32-byte key + ) + } + + /** + * Attempt to detect ephemeralPub length. Typically 32 for Curve25519. + * If your ephemeral keys might be 33, adjust logic as needed. + */ + private fun getEphemeralPubLength(ciphertext: ByteArray): Int { + if (ciphertext.size < 32 + 12) { + throw InvalidCipherTextException("Ciphertext too short to contain ephemeralPub + IV") + } + return 32 + } +} + +/** + * Demonstrates ephemeral ECDH encryption ("ECIES") with ephemeral Curve25519 keys + * for **each message**, providing forward secrecy in a simplified manner. + * + * Layout of the resulting ciphertext: + * [ ephemeralPub(32 bytes) || iv(12 bytes) || AES/GCM ciphertext+tag(...) ]. + * + * 1) For each message: + * - Generate ephemeral key pair (sender). + * - ECDH with recipient’s long-term public key => sharedSecret. + * - HKDF => AES key. + * - AES/GCM => final ciphertext. + * 2) Recipient uses ephemeralPub + their private key to derive the same AES key. + */ +object EphemeralECDHUtils { + + private const val AES_KEY_SIZE = 32 // 256-bit AES key + private const val IV_SIZE = 12 // 96-bit GCM IV + private const val GCM_TAG_BITS = 128 + private val secureRandom = SecureRandom() + + /** + * Encrypt [plaintext] using ephemeral Curve25519 for each message. + * [recipientPub] is the recipient's **long-term** public key. + */ + fun encrypt(plaintext: ByteArray, recipientPub: ECPublicKey): ByteArray { + // 1) Generate ephemeral key pair + val ephemeralPair = Curve.generateKeyPair() // ephemeral keys + val ephemeralPubBytes = ephemeralPair.publicKey.serialize() // 32 bytes + + // 2) ECDH => shared secret + val sharedSecret = Curve.calculateAgreement(recipientPub, ephemeralPair.privateKey) + + // 3) HKDF => 256-bit AES key + val aesKey = deriveAesKey(sharedSecret, "Ephemeral-FS") + + // 4) AES/GCM encrypt + val iv = ByteArray(IV_SIZE).also { secureRandom.nextBytes(it) } + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(aesKey, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) + val ciphertext = cipher.doFinal(plaintext) + + // 5) Combine ephemeralPub + iv + ciphertext + return ByteBuffer.allocate(ephemeralPubBytes.size + iv.size + ciphertext.size) + .put(ephemeralPubBytes) + .put(iv) + .put(ciphertext) + .array() + } + + /** + * Decrypt using recipient's **private** key + ephemeral pub from the ciphertext. + */ + fun decrypt(ciphertext: ByteArray, recipientPrivate: ECPrivateKey): ByteArray { + if (ciphertext.size < 32 + IV_SIZE) { + throw IllegalArgumentException("Ciphertext too small. Expected at least 44 bytes.") + } + + Timber.e("Decrypting ciphertext of size: ${ciphertext.size}") + // 1) Extract ephemeralPub(32 bytes) from the front + val ephemeralPubBytes = ciphertext.sliceArray(0 until 32) + val ephemeralPub = Curve.decodePoint(ephemeralPubBytes, 0) + + // 2) Next 12 bytes => IV + val ivStart = 32 + val ivEnd = ivStart + IV_SIZE + val iv = ciphertext.sliceArray(ivStart until ivEnd) + + // 3) Remainder => GCM ciphertext + val actualCipher = ciphertext.sliceArray(ivEnd until ciphertext.size) + + // 4) ECDH => sharedSecret + val sharedSecret = Curve.calculateAgreement(ephemeralPub, recipientPrivate) + + // 5) HKDF => AES key + val aesKey = deriveAesKey(sharedSecret, "Ephemeral-FS") + + // 6) AES/GCM decrypt + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) + return cipher.doFinal(actualCipher) + } + + private fun deriveAesKey(sharedSecret: ByteArray, info: String): ByteArray { + // Use org.signal.libsignal.protocol.kdf.HKDF (SHA-256 based) + // No salt => pass ByteArray(0) + return HKDF.deriveSecrets( + sharedSecret, + ByteArray(0), + info.toByteArray(), + AES_KEY_SIZE + ) + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt b/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt new file mode 100644 index 00000000..ca3ab30f --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt @@ -0,0 +1,43 @@ +package com.canopas.yourspace.data.utils + +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.protocol.state.PreKeyRecord +import org.signal.libsignal.protocol.state.SignedPreKeyRecord +import org.signal.libsignal.protocol.util.KeyHelper +import java.security.SecureRandom + +private const val INTEGER_MAX = 0x7fffffff + +object KeyHelper { + fun generateIdentityKeyPair(): IdentityKeyPair { + val keyPair = Curve.generateKeyPair() + val publicKey = IdentityKey(keyPair.publicKey) + return IdentityKeyPair(publicKey, keyPair.privateKey) + } + + fun generateRegistrationId(extendedRange: Boolean): Int { + return KeyHelper.generateRegistrationId(extendedRange) + } + + fun generatePreKeys(start: Int, count: Int): List { + val results = mutableListOf() + for (i in 0 until count) { + results.add(PreKeyRecord(start + i, Curve.generateKeyPair())) + } + return results + } + + fun generateSignedPreKey(identityKeyPair: IdentityKeyPair, signedPreKeyId: Int): SignedPreKeyRecord { + val keyPair = Curve.generateKeyPair() + val signature = + Curve.calculateSignature(identityKeyPair.privateKey, keyPair.publicKey.serialize()) + return SignedPreKeyRecord(signedPreKeyId, System.currentTimeMillis(), keyPair, signature) + } + + fun generateSignedPreKeyId(): Int { + val random = SecureRandom() + return random.nextInt(INTEGER_MAX) + } +} diff --git a/firestore.rules b/firestore.rules index 4cb4670c..bb5fdc98 100644 --- a/firestore.rules +++ b/firestore.rules @@ -1,293 +1,316 @@ rules_version = '2'; service cloud.firestore { - match /databases/{database}/documents { - - function isAuthorized() { - return request.auth != null; - } - - function readUserLocation() { - let requestedUserSpaceIds = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.space_ids; - let resourceUserSpaceIds = get(/databases/$(database)/documents/users/$(resource.data.user_id)).data.space_ids; - return requestedUserSpaceIds.hasAny(resourceUserSpaceIds); - } - - match /support_requests/{docId} { - allow create : if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["user_id", "title", "description", "device_name","app_version","device_os", "created_at"]) && - request.resource.data.user_id is string && - request.resource.data.title is string && - request.resource.data.description is string && - request.resource.data.device_name is string && - request.resource.data.app_version is string && - request.resource.data.device_os is string && - request.resource.data.created_at is timestamp && - request.resource.data.get('attachments', []) is list; - allow update: if false; - allow delete: if false; - allow read: if isAuthorized() && request.auth.uid == resource.data.user_id; - } - - match /users/{docId} { - allow create : if isAuthorized() && request.auth.uid == docId && - request.resource.data.keys().hasAll(["id", "auth_type", "location_enabled", "provider_firebase_id_token", "created_at"]) && - request.resource.data.email is string && - request.resource.data.id is string && - request.resource.data.auth_type is int && - (request.resource.data.auth_type == 1 || request.resource.data.auth_type == 2) && - request.resource.data.location_enabled is bool && - request.resource.data.provider_firebase_id_token is string && - request.resource.data.created_at is int && - request.resource.data.get('first_name', '') is string && - request.resource.data.get('email', '') is string && - request.resource.data.get('last_name', '') is string && - request.resource.data.get('fcm_token', '') is string && - request.resource.data.get('profile_image', '') is string && - request.resource.data.get('space_ids', []) is list; - - allow update: if isAuthorized() && request.auth.uid == resource.data.id && - request.resource.data.diff(resource.data).affectedKeys().hasAny(['first_name', 'last_name', 'profile_image', 'location_enabled', 'space_ids', 'phone', 'email','fcm_token', 'updated_at', 'battery_pct', 'state']) && - request.resource.data.first_name is string && - request.resource.data.get('last_name', '') is string && - request.resource.data.get('fcm_token', '') is string && - request.resource.data.location_enabled is bool && - request.resource.data.get('space_ids', []) is list; - - allow delete: if isAuthorized() && request.auth.uid == resource.data.id; - allow read: if isAuthorized(); - - match /user_locations/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.latitude is number && - request.resource.data.longitude is number && - request.resource.data.created_at is int; - } - - match /user_journeys/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.from_latitude is number && - request.resource.data.from_longitude is number && - request.resource.data.created_at is int; - } + match /databases/{database}/documents { + function isAuthorized() { + return request.auth != null; + } - match /user_sessions/{docId} { - allow read: if isAuthorized(); - allow create : if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "device_id", "device_name", "platform", "session_active", "app_version", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.device_id is string && - request.resource.data.device_name is string && - request.resource.data.platform is int && - request.resource.data.platform == 1 && - request.resource.data.session_active is bool && - request.resource.data.app_version is int && - request.resource.data.created_at is int; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - } - } + function readUserLocation() { + let requestedUserSpaceIds = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.space_ids; + let resourceUserSpaceIds = get(/databases/$(database)/documents/users/$(resource.data.user_id)).data.space_ids; + return requestedUserSpaceIds.hasAny(resourceUserSpaceIds); + } - match /users/{docId}/user_sessions/{document=**} { - allow read: if isAuthorized() && request.auth.uid == docId; - } + match /support_requests/{docId} { + allow create: if isAuthorized() && + request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["user_id", "title", "description", "device_name", "app_version", "device_os", "created_at"]) && + request.resource.data.user_id is string && + request.resource.data.title is string && + request.resource.data.description is string && + request.resource.data.device_name is string && + request.resource.data.app_version is string && + request.resource.data.device_os is string && + request.resource.data.created_at is timestamp && + request.resource.data.get('attachments', []) is list; + allow update: if false; + allow delete: if false; + allow read: if isAuthorized() && request.auth.uid == resource.data.user_id; + } - function isSpaceAdmin(spaceId) { - let adminId = get(/databases/$(database)/documents/spaces/$(spaceId)).data.admin_id; - return request.auth.uid == adminId; - } + match /users/{docId} { + allow create: if isAuthorized() && + request.auth.uid == docId && + request.resource.data.keys().hasAll(["id", "auth_type", "location_enabled", "provider_firebase_id_token", "created_at"]) && + request.resource.data.email is string && + request.resource.data.id is string && + request.resource.data.auth_type is int && + (request.resource.data.auth_type == 1 || request.resource.data.auth_type == 2) && + request.resource.data.location_enabled is bool && + request.resource.data.provider_firebase_id_token is string && + request.resource.data.created_at is int && + request.resource.data.get('first_name', '') is string && + request.resource.data.get('email', '') is string && + request.resource.data.get('last_name', '') is string && + request.resource.data.get('fcm_token', '') is string && + request.resource.data.get('profile_image', '') is string && + request.resource.data.get('space_ids', []) is list; + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.id; + allow read: if isAuthorized(); + + match /user_locations/{docId} { + allow read: if isAuthorized() && + (request.auth.uid == resource.data.user_id || readUserLocation()); + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && + request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.latitude is number && + request.resource.data.longitude is number && + request.resource.data.created_at is int; + } + + match /user_journeys/{docId} { + allow read: if isAuthorized() && + (request.auth.uid == resource.data.user_id || readUserLocation()); + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && + request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.from_latitude is number && + request.resource.data.from_longitude is number && + request.resource.data.created_at is int; + } + + match /user_sessions/{docId} { + allow read: if isAuthorized(); + allow create: if isAuthorized() && + request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "device_id", "device_name", "platform", "session_active", "app_version", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.device_id is string && + request.resource.data.device_name is string && + request.resource.data.platform is int && + request.resource.data.platform == 1 && + request.resource.data.session_active is bool && + request.resource.data.app_version is int && + request.resource.data.created_at is int; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + } + } - function isSpaceMember(spaceId) { - let isMember = exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); - return isMember; - } + match /users/{docId}/user_sessions/{document=**} { + allow read: if isAuthorized() && request.auth.uid == docId; + } - match /spaces/{docId} { - allow read: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.admin_id; - allow update: if isAuthorized() && request.auth.uid == resource.data.admin_id; - allow create: if isAuthorized() && - request.resource.data.keys().hasAll(["id", "admin_id", "name", "created_at"]) && - request.resource.data.id is string && - request.resource.data.admin_id is string && - request.resource.data.name is string && - request.resource.data.created_at is int; + function isSpaceAdmin(spaceId) { + let adminId = get(/databases/$(database)/documents/spaces/$(spaceId)).data.admin_id; + return request.auth.uid == adminId; + } + function isSpaceMember(spaceId) { + let isMember = exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); + return isMember; + } - } - match /{path=**}/space_places/{place} { - allow read: if isAuthorized() && isSpaceMember(resource.data.space_id); - allow write: if false; - } + match /spaces/{spaceId} { + allow read: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.admin_id; + allow update: if isAuthorized() && request.auth.uid == resource.data.admin_id; + allow create: if isAuthorized() && + request.resource.data.keys().hasAll(["id", "admin_id", "name", "created_at"]) && + request.resource.data.id is string && + request.resource.data.admin_id is string && + request.resource.data.name is string && + request.resource.data.created_at is int; + } - match /spaces/{spaceId}/space_places/{place} { - allow read: if isAuthorized() && isSpaceMember(spaceId); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.created_by); - allow update: if isAuthorized() && request.auth.uid == resource.data.created_by; - allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.created_by) && - request.resource.data.keys().hasAll(["id", "space_id", "created_by", "latitude", "longitude", "radius", "name", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.created_by is string && - request.resource.data.latitude is number && - request.resource.data.longitude is number && - request.resource.data.radius is number && - request.resource.data.name is string && - request.resource.data.created_at is timestamp; - } + match /{path=**}/space_places/{place} { + allow read: if isAuthorized() && isSpaceMember(resource.data.space_id); + allow write: if false; + } - function isPlaceAdmin(spaceId, place) { - let created_by = get(/databases/$(database)/documents/spaces/$(spaceId)/space_places/$(place)).data.created_by; - return request.auth.uid == created_by; - } + match /spaces/{spaceId}/space_places/{place} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + allow delete: if isAuthorized() && + (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.created_by); + allow update: if isAuthorized() && request.auth.uid == resource.data.created_by; + allow create: if isAuthorized() && + (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.created_by) && + request.resource.data.keys().hasAll(["id", "space_id", "created_by", "latitude", "longitude", "radius", "name", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.created_by is string && + request.resource.data.latitude is number && + request.resource.data.longitude is number && + request.resource.data.radius is number && + request.resource.data.name is string && + request.resource.data.created_at is timestamp; + } - match /spaces/{spaceId}/space_places/{place}/place_settings_by_members/{member} { - allow read: if isAuthorized() && isSpaceMember(spaceId); - allow update: if isAuthorized() && isSpaceMember(spaceId) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["alert_enable", "leave_alert_for", "arrival_alert_for"]) && - request.resource.data.arrival_alert_for is list && - request.resource.data.leave_alert_for is list && - request.resource.data.alert_enable is bool; - allow delete: if isAuthorized() && (request.auth.uid == resource.data.user_id || isPlaceAdmin(place)); - allow create: if isAuthorized() && isSpaceMember(spaceId) && - request.resource.data.keys().hasAll(["user_id", "place_id", "alert_enable", "leave_alert_for", "arrival_alert_for"]) && - request.resource.data.user_id is string && - request.resource.data.place_id is string && - request.resource.data.get('arrival_alert_for', []) is list && - request.resource.data.get('leave_alert_for', []) is list && - request.resource.data.alert_enable is bool; - } + function isPlaceAdmin(spaceId, place) { + let created_by = get(/databases/$(database)/documents/spaces/$(spaceId)/space_places/$(place)).data.created_by; + return request.auth.uid == created_by; + } - match /{path=**}/space_members/{member} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(resource.data.space_id)); - allow write: if false; - } + match /spaces/{spaceId}/space_places/{place}/place_settings_by_members/{member} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + allow update: if isAuthorized() && + isSpaceMember(spaceId) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["alert_enable", "leave_alert_for", "arrival_alert_for"]) && + request.resource.data.arrival_alert_for is list && + request.resource.data.leave_alert_for is list && + request.resource.data.alert_enable is bool; + allow delete: if isAuthorized() && + (request.auth.uid == resource.data.user_id || isPlaceAdmin(spaceId, place)); + allow create: if isAuthorized() && + isSpaceMember(spaceId) && + request.resource.data.keys().hasAll(["user_id", "place_id", "alert_enable", "leave_alert_for", "arrival_alert_for"]) && + request.resource.data.user_id is string && + request.resource.data.place_id is string && + request.resource.data.get('arrival_alert_for', []) is list && + request.resource.data.get('leave_alert_for', []) is list && + request.resource.data.alert_enable is bool; + } - match /spaces/{spaceId}/space_members/{member} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.user_id); - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["location_enabled"]) && - request.resource.data.location_enabled is bool; - allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.user_id) && - request.resource.data.keys().hasAll(["id", "space_id", "user_id", "role", "location_enabled", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.user_id is string && - request.resource.data.role is int && - (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 /{path=**}/space_members/{member} { + allow read: if isAuthorized() && + (request.auth.uid == resource.data.user_id || isSpaceMember(resource.data.space_id)); + allow write: if false; + } - function readSpaceMemberLocation(spaceId) { - return exists(/databases/$(database)/documents/spaces/$(spaceId)/space_members/$(request.auth.uid)); - } + match /spaces/{spaceId}/group_keys/{senderId} { + allow read: if isAuthorized() && isSpaceMember(spaceId); - match /user_locations/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readSpaceMemberLocation()); - allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "sender_key", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.latitude is string && - request.resource.data.longitude is string && - request.resource.data.sender_key is string && - request.resource.data.created_at is int; - } + allow create: if isAuthorized() && + request.auth.uid == senderId && + isSpaceMember(spaceId) && + request.resource.data.keys().hasAll(["senderId", "distributions", "createdAt"]) && + request.resource.data.senderId is string && + request.resource.data.distributions is list && + request.resource.data.createdAt is int; - match /user_journeys/{docId} { - allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readSpaceMemberLocation()); - allow update: if true; - allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && - request.resource.data.id is string && - request.resource.data.user_id is string && - request.resource.data.from_latitude is string && - request.resource.data.from_longitude is string && - request.resource.data.created_at is int; - } - } + allow update: if false; + allow delete: if false; + } - match /space_invitations/{docId} { - allow read: if isAuthorized(); - allow delete: if isAuthorized() && isSpaceAdmin(resource.data.space_id); - allow update: if isAuthorized() && isSpaceMember(resource.data.space_id) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["code", "created_at"]) && - request.resource.data.code is string && - request.resource.data.code.size() == 6 && - request.resource.data.created_at is int; + match /spaces/{spaceId}/space_members/{member} { + allow read: if isAuthorized() && + (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); + allow delete: if isAuthorized() && + (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.user_id); + allow update: if isAuthorized() && + (request.auth.uid == resource.data.user_id || isSpaceAdmin(resource.data.space_id)) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["location_enabled", "role"]) && + ((request.resource.data.location_enabled is bool) || + (request.resource.data.role is int && (request.resource.data.role == 1 || request.resource.data.role == 2))); + allow create: if isAuthorized() && + (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.user_id) && + request.resource.data.keys().hasAll(["id", "space_id", "user_id", "role", "location_enabled", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.user_id is string && + request.resource.data.role is int && + (request.resource.data.role == 1 || request.resource.data.role == 2) && + request.resource.data.location_enabled is bool && + request.resource.data.created_at is int; + } - allow create: if isAuthorized() && isSpaceAdmin(request.resource.data.space_id) && - request.resource.data.keys().hasAll(["id", "code", "space_id", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.code is string && - request.resource.data.code.size() == 6 && - 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 || readUserLocation()); + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized(); + } + + match /user_journeys/{docId} { + allow read: if isAuthorized() && + (request.auth.uid == resource.data.user_id || readUserLocation()); + allow update: if true; + allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; + allow create: if isAuthorized() && + request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.from_latitude is number && + request.resource.data.from_longitude is number && + request.resource.data.created_at is int; + } + } + match /space_invitations/{docId} { + allow read: if isAuthorized(); + allow delete: if isAuthorized() && isSpaceAdmin(resource.data.space_id); + allow update: if isAuthorized() && + isSpaceMember(resource.data.space_id) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["code", "created_at"]) && + request.resource.data.code is string && + request.resource.data.code.size() == 6 && + request.resource.data.created_at is int; + allow create: if isAuthorized() && + isSpaceAdmin(request.resource.data.space_id) && + request.resource.data.keys().hasAll(["id", "code", "space_id", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.code is string && + request.resource.data.code.size() == 6 && + request.resource.data.created_at is int; + } - match /space_threads/{docId} { - allow read: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || isSpaceMember(resource.data.space_id)); - allow delete: if isAuthorized() && isThreadAdmin(docId); - allow update: if isAuthorized() && isThreadMember(docId) && - request.resource.data.diff(resource.data).affectedKeys().hasAny(["member_ids", "archived_for"]) && - request.resource.data.get('member_ids', []) is list && - request.resource.data.get('archived_for', {}) is map; - allow create: if isAuthorized() && (isSpaceMember(request.resource.data.space_id)) && - request.resource.data.keys().hasAll(["id", "space_id", "admin_id", "member_ids", "created_at"]) && - request.resource.data.id is string && - request.resource.data.space_id is string && - request.resource.data.admin_id is string && - request.resource.data.member_ids is list && - request.resource.data.created_at is int; + match /space_threads/{docId} { + allow read: if isAuthorized() && + (isSpaceAdmin(resource.data.space_id) || isSpaceMember(resource.data.space_id)); + allow delete: if isAuthorized() && isThreadAdmin(docId); + allow update: if isAuthorized() && + isThreadMember(docId) && + request.resource.data.diff(resource.data).affectedKeys().hasAny(["member_ids", "archived_for"]) && + request.resource.data.get('member_ids', []) is list && + request.resource.data.get('archived_for', {}) is map; + allow create: if isAuthorized() && + (isSpaceMember(request.resource.data.space_id)) && + request.resource.data.keys().hasAll(["id", "space_id", "admin_id", "member_ids", "created_at"]) && + request.resource.data.id is string && + request.resource.data.space_id is string && + request.resource.data.admin_id is string && + request.resource.data.member_ids is list && + request.resource.data.created_at is int; + } - } - function isThreadMember(threadId) { - let memberIds = get(/databases/$(database)/documents/space_threads/$(threadId)).data.member_ids; - return memberIds.hasAny([request.auth.uid]); - } + function isThreadMember(threadId) { + let memberIds = get(/databases/$(database)/documents/space_threads/$(threadId)).data.member_ids; + return memberIds.hasAny([request.auth.uid]); + } - function isThreadAdmin(threadId) { - let adminId = get(/databases/$(database)/documents/space_threads/$(threadId)).data.admin_id; - return adminId == request.auth.uid; - } + function isThreadAdmin(threadId) { + let adminId = get(/databases/$(database)/documents/space_threads/$(threadId)).data.admin_id; + return adminId == request.auth.uid; + } - match /{path=**}/thread_messages/{docId} { - allow read: if isAuthorized() && isThreadMember(resource.data.thread_id); - } + match /{path=**}/thread_messages/{docId} { + allow read: if isAuthorized() && isThreadMember(resource.data.thread_id); + } - match /space_threads/{threadId}/thread_messages/{docId} { - allow read: if isAuthorized() && isThreadMember(threadId); - allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id)); - allow update: if isAuthorized() && isThreadMember(threadId) && - request.resource.data.diff(resource.data).affectedKeys().hasOnly(["seen_by"]) && - request.resource.data.seen_by is list; - allow create: if isAuthorized() && isThreadMember(threadId) && request.resource.data.sender_id == request.auth.uid && - request.resource.data.keys().hasAll(["id", "thread_id", "sender_id", "message", "seen_by", "created_at"]) && - request.resource.data.id is string && - request.resource.data.thread_id is string && - request.resource.data.sender_id is string && - request.resource.data.message is string && - request.resource.data.seen_by is list && - request.resource.data.created_at is timestamp; - } + match /space_threads/{threadId}/thread_messages/{docId} { + allow read: if isAuthorized() && isThreadMember(threadId); + allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id)); + allow update: if isAuthorized() && + isThreadMember(threadId) && + request.resource.data.diff(resource.data).affectedKeys().hasOnly(["seen_by"]) && + request.resource.data.seen_by is list; + allow create: if isAuthorized() && + isThreadMember(threadId) && + request.resource.data.sender_id == request.auth.uid && + request.resource.data.keys().hasAll(["id", "thread_id", "sender_id", "message", "seen_by", "created_at"]) && + request.resource.data.id is string && + request.resource.data.thread_id is string && + request.resource.data.sender_id is string && + request.resource.data.message is string && + request.resource.data.seen_by is list && + request.resource.data.created_at is timestamp; } + } } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 2cbd6d19..957f8ef9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -21,3 +21,4 @@ kotlin.code.style=official # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +android.enableR8.fullMode=false From 582e99e2effa278dcdb5ee30918da9a73f22857d Mon Sep 17 00:00:00 2001 From: cp-megh Date: Fri, 27 Dec 2024 12:24:19 +0530 Subject: [PATCH 06/30] WIP --- .../canopas/yourspace/YourSpaceApplication.kt | 2 +- .../yourspace/data/di/AppDataProvider.kt | 25 +- .../yourspace/data/models/space/ApiSpace.kt | 20 +- .../service/location/ApiLocationService.kt | 58 ++-- .../data/service/space/ApiSpaceService.kt | 31 +-- .../data/service/user/ApiUserService.kt | 6 +- .../BufferedSenderKeyStore.kt | 87 ++++++ .../bufferedkeystore/DistributionId.kt | 61 +++++ .../SignalServiceSenderKeyStore.kt | 28 ++ .../data/storage/database/AppDatabase.kt | 13 + .../data/storage/database/SenderKeyDao.kt | 65 +++++ .../data/storage/database/SenderKeyEntity.kt | 41 +++ .../yourspace/data/utils/EncryptionUtils.kt | 251 ------------------ .../data/utils/EphemeralECDHUtils.kt | 144 ++++++++++ .../canopas/yourspace/data/utils/KeyHelper.kt | 5 + 15 files changed, 515 insertions(+), 322 deletions(-) create mode 100644 data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/DistributionId.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/storage/database/AppDatabase.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyEntity.kt delete mode 100644 data/src/main/java/com/canopas/yourspace/data/utils/EncryptionUtils.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt diff --git a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt index 260071a3..b2edf095 100644 --- a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt +++ b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt @@ -53,7 +53,7 @@ class YourSpaceApplication : super.onCreate() Timber.plant(Timber.DebugTree()) - FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = false + FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = true ProcessLifecycleOwner.get().lifecycle.addObserver(this) authService.addListener(this) setNotificationChannel() diff --git a/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt b/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt index 52ea0252..437d29a5 100644 --- a/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt +++ b/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt @@ -1,7 +1,10 @@ package com.canopas.yourspace.data.di import android.content.Context +import androidx.room.Room import com.canopas.yourspace.data.models.user.ApiUserSession +import com.canopas.yourspace.data.storage.database.AppDatabase +import com.canopas.yourspace.data.storage.database.SenderKeyDao import com.canopas.yourspace.data.utils.Config import com.google.android.gms.location.GeofencingClient import com.google.android.gms.location.LocationServices @@ -18,8 +21,11 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import javax.inject.Named import javax.inject.Singleton +private const val DATABASE_NAME = "sender_keys_db" + @Module @InstallIn(SingletonComponent::class) class AppDataProvider { @@ -54,5 +60,22 @@ class AppDataProvider { @Provides @Singleton - fun provideAppContext(@ApplicationContext context: Context): Context = context + fun provideAppDatabase( + @ApplicationContext appContext: Context + ): AppDatabase { + return Room.databaseBuilder( + appContext, + AppDatabase::class.java, + DATABASE_NAME + ) + .fallbackToDestructiveMigration() + .build() + } + + @Provides + @Singleton + @Named("sender_key_dao") + fun provideSenderKeyDao(appDatabase: AppDatabase): SenderKeyDao { + return appDatabase.senderKeyDao() + } } 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 21080ec8..98666176 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 @@ -52,8 +52,9 @@ data class ApiSpaceInvitation( * Data class that represents the entire "groupKeys/{senderUserId}" doc * in Firestore for a single sender's key distribution. */ -data class SenderKeyDistributionDoc( +data class SenderKeyDistribution( val senderId: String = "", + val senderDeviceId: Int = 0, val distributions: List = emptyList(), val createdAt: Long = 0 ) @@ -64,15 +65,8 @@ data class SenderKeyDistributionDoc( * encrypted with the recipient's public key. */ data class EncryptedDistribution( - val recipientId: String, - val deviceId: Blob, - val ciphertext: Blob -) { - fun toMap(): Map { - return mapOf( - "recipientId" to recipientId, - "deviceId" to deviceId, - "ciphertext" to ciphertext - ) - } -} + 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 +) 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 5b49cdcc..d22e25b3 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 @@ -2,7 +2,9 @@ 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.SenderKeyDistribution import com.canopas.yourspace.data.storage.UserPreferences +import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS @@ -15,10 +17,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await import org.signal.libsignal.protocol.SignalProtocolAddress -import org.signal.libsignal.protocol.ecc.ECPrivateKey +import org.signal.libsignal.protocol.ecc.Curve import org.signal.libsignal.protocol.groups.GroupCipher import org.signal.libsignal.protocol.groups.GroupSessionBuilder -import org.signal.libsignal.protocol.groups.state.InMemorySenderKeyStore import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage import timber.log.Timber import javax.inject.Inject @@ -28,7 +29,8 @@ import javax.inject.Singleton class ApiLocationService @Inject constructor( db: FirebaseFirestore, private val locationManager: LocationManager, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val bufferedSenderKeyStore: BufferedSenderKeyStore ) { var currentSpaceId: String = userPreferences.currentSpace ?: "" @@ -56,6 +58,7 @@ class ApiLocationService @Inject constructor( val cipherAndDistributionMessage = getGroupCipherAndDistributionMessage(spaceId, userId) val groupCipher = cipherAndDistributionMessage?.second ?: return val distributionMessage = cipherAndDistributionMessage.first + Timber.e("XXXXXX: Distribution id: ${distributionMessage.distributionId}") val lat = groupCipher.encrypt( distributionMessage.distributionId, lastLocation.latitude.toString().toByteArray(Charsets.UTF_8) @@ -86,10 +89,8 @@ class ApiLocationService @Inject constructor( longitude: Double, recordedAt: Long ) { - Timber.e("Saving current location for user $userId") userPreferences.currentUser?.space_ids?.forEach { spaceId -> val cipherAndDistributionMessage = getGroupCipherAndDistributionMessage(spaceId, userId) - Timber.e("Cipher and distribution message: $cipherAndDistributionMessage") val groupCipher = cipherAndDistributionMessage?.second ?: return val distributionMessage = cipherAndDistributionMessage.first val lat = groupCipher.encrypt( @@ -101,8 +102,6 @@ class ApiLocationService @Inject constructor( longitude.toString().toByteArray(Charsets.UTF_8) ) - Timber.d("Current location: $latitude, $longitude\nLat: $lat\nLon: $lon") - val docRef = spaceMemberLocationRef(spaceId, userId).document() val location = EncryptedApiLocation( @@ -129,18 +128,17 @@ class ApiLocationService @Inject constructor( val receiverGroupCipher = getGroupCipherAndDistributionMessage(currentSpaceId, userId)?.second ?: return@map null - Timber.e("Receiver group cipher: $receiverGroupCipher") val lat = receiverGroupCipher.decrypt(encryptedLocation.encrypted_latitude.toBytes()) val lon = receiverGroupCipher.decrypt(encryptedLocation.encrypted_longitude.toBytes()) - Timber.d("Decrypted location: $lat, $lon") - ApiLocation( + id = encryptedLocation.id, user_id = userId, latitude = lat.toString(Charsets.UTF_8).toDouble(), - longitude = lon.toString(Charsets.UTF_8).toDouble() + longitude = lon.toString(Charsets.UTF_8).toDouble(), + created_at = encryptedLocation.created_at ) } emit(apiLocations) @@ -156,34 +154,24 @@ class ApiLocationService @Inject constructor( userId: String ): Pair? { val currentUser = userPreferences.currentUser ?: return null - Timber.e("Getting group cipher for space $spaceId\tUser: $userId") - val sharedDistributionMessage = spaceGroupKeysRef(spaceId) - .document(userId).get().await() - val distributions = - sharedDistributionMessage["distributions"] as? List> ?: emptyList() - val currentUserDistribution = - distributions.firstOrNull { it["recipientId"] == currentUser.id } ?: return null - val currentUserCiphertext = - (currentUserDistribution["ciphertext"] as? Blob)?.toBytes() ?: ByteArray(0) - val deviceIdBytes = - (currentUserDistribution["deviceId"] as? Blob)?.toBytes() ?: ByteArray(0) - val currentUserPrivateKey = - ECPrivateKey(currentUser.identity_key_private?.toBytes() ?: ByteArray(0)) - val distributionBytes = - EphemeralECDHUtils.decrypt(currentUserCiphertext, currentUserPrivateKey) - val deviceId = EphemeralECDHUtils.decrypt(deviceIdBytes, currentUserPrivateKey) - val distributionMessage = SenderKeyDistributionMessage(distributionBytes) - val receiverKeyStore = InMemorySenderKeyStore() + val senderKeyDistribution = spaceGroupKeysRef(spaceId) + .document(userId).get().await().toObject(SenderKeyDistribution::class.java) + val distributions = senderKeyDistribution?.distributions ?: return null + val currentUserDistribution = distributions.firstOrNull { it.recipientId == currentUser.id } + ?: return null + val currentUserPrivateKey = Curve.decodePrivatePoint(currentUser.identity_key_private?.toBytes()) + + val decryptedDistribution = + EphemeralECDHUtils.decrypt(currentUserDistribution, currentUserPrivateKey) + + val distributionMessage = SenderKeyDistributionMessage(decryptedDistribution) val senderAddress = SignalProtocolAddress( spaceId, - deviceId.toString(Charsets.UTF_8).toInt() + senderKeyDistribution.senderDeviceId ) - val sessionBuilder = GroupSessionBuilder(receiverKeyStore) - val receiverGroupCipher = GroupCipher(receiverKeyStore, senderAddress) + val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) + val receiverGroupCipher = GroupCipher(bufferedSenderKeyStore, senderAddress) sessionBuilder.process(senderAddress, distributionMessage) - Timber.e("Group cipher created for space $spaceId") - // Log everything created here - Timber.e("Distribution message: $distributionMessage\nGroup cipher: $receiverGroupCipher") return Pair(distributionMessage, receiverGroupCipher) } } 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 f49b1b94..da6655ea 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 @@ -5,17 +5,17 @@ import com.canopas.yourspace.data.models.space.ApiSpaceMember import com.canopas.yourspace.data.models.space.EncryptedDistribution import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_ADMIN import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_MEMBER -import com.canopas.yourspace.data.models.space.SenderKeyDistributionDoc +import com.canopas.yourspace.data.models.space.SenderKeyDistribution import com.canopas.yourspace.data.service.auth.AuthService import com.canopas.yourspace.data.service.place.ApiPlaceService import com.canopas.yourspace.data.service.user.ApiUserService import com.canopas.yourspace.data.storage.UserPreferences +import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS 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.Blob import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await import org.signal.libsignal.protocol.SignalProtocolAddress @@ -33,7 +33,8 @@ class ApiSpaceService @Inject constructor( private val authService: AuthService, private val apiUserService: ApiUserService, private val placeService: ApiPlaceService, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val bufferedSenderKeyStore: BufferedSenderKeyStore ) { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) private fun spaceMemberRef(spaceId: String) = @@ -86,15 +87,14 @@ class ApiSpaceService @Inject constructor( } /** - * Create a sender key distribution for the current/joining user, and encrypt that distribution - * for each member. + * Create a sender key distribution for the current/joining user, and encrypt the distribution key + * for each member using their public key(ECDH). **/ private suspend fun distributeSenderKeyToSpaceMembers(spaceId: String, senderUserId: String) { val deviceId = userPreferences.currentUserSession?.device_id ?: "" val deviceIdInt = deviceId.hashCode() and 0x7FFFFFFF val groupAddress = SignalProtocolAddress(spaceId, deviceIdInt) - val senderKeyStore = InMemorySenderKeyStore() - val sessionBuilder = GroupSessionBuilder(senderKeyStore) + val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) val distributionMessage = sessionBuilder.create(groupAddress, UUID.fromString(spaceId)) val distributionBytes = distributionMessage.serialize() @@ -106,21 +106,14 @@ class ApiSpaceService @Inject constructor( val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) // Encrypt distribution using member's public key - val encBytes = EphemeralECDHUtils.encrypt(distributionBytes, publicKey) - val encDeviceId = EphemeralECDHUtils.encrypt(deviceIdInt.toString().toByteArray(), publicKey) - distributions.add( - EncryptedDistribution( - recipientId = member.user_id, - deviceId = Blob.fromBytes(encDeviceId), - ciphertext = Blob.fromBytes(encBytes) - ) - ) + distributions.add(EphemeralECDHUtils.encrypt(member.user_id, distributionBytes, publicKey)) } val docRef = spaceGroupKeysRef(spaceId).document(senderUserId) - val data = SenderKeyDistributionDoc( + val data = SenderKeyDistribution( senderId = senderUserId, + senderDeviceId = deviceIdInt, distributions = distributions, createdAt = System.currentTimeMillis() ) @@ -179,3 +172,7 @@ class ApiSpaceService @Inject constructor( spaceRef.document(space.id).set(space).await() } } + +fun InMemorySenderKeyStore.createSenderKeyRecord(spaceId: String, deviceId: Int): ByteArray { + return "$spaceId-$deviceId".toByteArray() +} 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 b8e525e1..84798342 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 @@ -1,6 +1,5 @@ package com.canopas.yourspace.data.service.user -import android.content.Context import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.models.user.ApiUserSession import com.canopas.yourspace.data.models.user.LOGIN_TYPE_APPLE @@ -32,8 +31,7 @@ class ApiUserService @Inject constructor( db: FirebaseFirestore, private val device: Device, private val locationService: ApiLocationService, - private val functions: FirebaseFunctions, - val context: Context + private val functions: FirebaseFunctions ) { private val userRef = db.collection(FIRESTORE_COLLECTION_USERS) private fun sessionRef(userId: String) = @@ -88,7 +86,7 @@ class ApiUserService @Inject constructor( provider_firebase_id_token = firebaseToken, profile_image = account?.photoUrl?.toString() ?: firebaseUser?.photoUrl?.toString() ?: "", - identity_key_public = Blob.fromBytes(identityKeyPair.publicKey.serialize()), + identity_key_public = Blob.fromBytes(identityKeyPair.publicKey.publicKey.serialize()), identity_key_private = Blob.fromBytes(identityKeyPair.privateKey.serialize()) ) userRef.document(uid).set(user).await() 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 new file mode 100644 index 00000000..9952a44a --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/BufferedSenderKeyStore.kt @@ -0,0 +1,87 @@ +package com.canopas.yourspace.data.storage.bufferedkeystore + +import com.canopas.yourspace.data.storage.database.SenderKeyDao +import com.canopas.yourspace.data.storage.database.SenderKeyEntity +import kotlinx.coroutines.runBlocking +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.groups.state.SenderKeyRecord +import java.util.UUID +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +/** + * An in-memory sender key store that is intended to be used temporarily while decrypting messages. + */ +@Singleton +class BufferedSenderKeyStore @Inject constructor( + @Named("sender_key_dao") private val senderKeyDao: SenderKeyDao +) : SignalServiceSenderKeyStore { + + private val store: MutableMap = HashMap() + + /** All of the keys that have been created or updated during operation. */ + private val updatedKeys: MutableMap = mutableMapOf() + + /** All of the distributionId's whose sharing has been cleared during operation. */ + private val clearSharedWith: MutableSet = mutableSetOf() + + override fun storeSenderKey(sender: SignalProtocolAddress, distributionId: UUID, record: SenderKeyRecord) { + val key = StoreKey(sender, distributionId) + store[key] = record + updatedKeys[key] = record + + runBlocking { + senderKeyDao.insertSenderKey( + senderKeyEntity = SenderKeyEntity( + address = sender.name, + deviceId = sender.deviceId, + distributionId = distributionId.toString(), + record = record.serialize() + ) + ) + } + } + + override fun loadSenderKey(sender: SignalProtocolAddress, distributionId: UUID): SenderKeyRecord? { + return store[StoreKey(sender, distributionId)] + ?: runBlocking { + val fromDatabase: SenderKeyRecord? = + senderKeyDao.getSenderKeyRecord( + address = sender.name, + deviceId = sender.deviceId, + distributionId = distributionId.toString() + ) + + if (fromDatabase != null) { + store[StoreKey(sender, distributionId)] = fromDatabase + } + + fromDatabase + } + } + + override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet { + error("Should not happen during the intended usage pattern of this class") + } + + override fun markSenderKeySharedWith( + distributionId: DistributionId?, + addresses: Collection? + ) { + error("Should not happen during the intended usage pattern of this class") + } + + override fun clearSenderKeySharedWith(addresses: Collection?) { + addresses?.forEach { address -> + address?.let { clearSharedWith.add(it) } + } + } + + private fun UUID.toDistributionId() = DistributionId.from(this) + + data class StoreKey( + val address: SignalProtocolAddress, + val distributionId: UUID + ) +} 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 new file mode 100644 index 00000000..98547e40 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/DistributionId.kt @@ -0,0 +1,61 @@ +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 new file mode 100644 index 00000000..0ec2bc41 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/storage/bufferedkeystore/SignalServiceSenderKeyStore.kt @@ -0,0 +1,28 @@ +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?) +} diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/database/AppDatabase.kt b/data/src/main/java/com/canopas/yourspace/data/storage/database/AppDatabase.kt new file mode 100644 index 00000000..66aa1313 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/storage/database/AppDatabase.kt @@ -0,0 +1,13 @@ +package com.canopas.yourspace.data.storage.database + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [SenderKeyEntity::class], + version = 1, + exportSchema = true +) +abstract class AppDatabase : RoomDatabase() { + abstract fun senderKeyDao(): SenderKeyDao +} diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt new file mode 100644 index 00000000..3fa856a2 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt @@ -0,0 +1,65 @@ +package com.canopas.yourspace.data.storage.database + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import org.signal.libsignal.protocol.groups.state.SenderKeyRecord + +@Dao +interface SenderKeyDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertSenderKey(senderKeyEntity: SenderKeyEntity) + + @Query( + """ + SELECT * FROM sender_keys + WHERE address = :address + AND device_id = :deviceId + AND distribution_id = :distributionId + LIMIT 1 + """ + ) + suspend fun getSenderKey( + address: String, + deviceId: Int, + distributionId: String + ): SenderKeyEntity? + + suspend fun getSenderKeyRecord( + address: String, + deviceId: Int, + distributionId: String + ): SenderKeyRecord? { + val entity = getSenderKey(address, deviceId, distributionId) + return entity?.let { + try { + SenderKeyRecord(it.record) + } catch (e: Exception) { + null + } + } + } + + @Query( + """ + DELETE FROM sender_keys + WHERE address = :address + AND device_id = :deviceId + AND distribution_id = :distributionId + """ + ) + suspend fun deleteSenderKey( + address: String, + deviceId: Int, + distributionId: String + ) + + @Query( + """ + SELECT * FROM sender_keys + """ + ) + suspend fun getAllSenderKeys(): List +} 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 new file mode 100644 index 00000000..ca21e7af --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyEntity.kt @@ -0,0 +1,41 @@ +package com.canopas.yourspace.data.storage.database + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "sender_keys") +data class SenderKeyEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "address") 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 = "created_at") val createdAt: Long = System.currentTimeMillis() +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SenderKeyEntity + + if (id != other.id) return false + if (address != other.address) return false + if (deviceId != other.deviceId) return false + if (distributionId != other.distributionId) return false + if (!record.contentEquals(other.record)) return false + if (createdAt != other.createdAt) return false + + return true + } + + override fun hashCode(): Int { + var result = id.hashCode() + result = 31 * result + address.hashCode() + result = 31 * result + deviceId + result = 31 * result + distributionId.hashCode() + result = 31 * result + record.contentHashCode() + result = 31 * result + createdAt.hashCode() + return result + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/EncryptionUtils.kt b/data/src/main/java/com/canopas/yourspace/data/utils/EncryptionUtils.kt deleted file mode 100644 index 895448c5..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/utils/EncryptionUtils.kt +++ /dev/null @@ -1,251 +0,0 @@ -package com.canopas.yourspace.data.utils - -import org.bouncycastle.crypto.InvalidCipherTextException -import org.bouncycastle.jce.provider.BouncyCastleProvider -import org.signal.libsignal.protocol.ecc.Curve -import org.signal.libsignal.protocol.ecc.ECPrivateKey -import org.signal.libsignal.protocol.ecc.ECPublicKey -import org.signal.libsignal.protocol.kdf.HKDF -import timber.log.Timber -import java.nio.ByteBuffer -import java.security.PrivateKey -import java.security.PublicKey -import java.security.SecureRandom -import java.security.Security -import javax.crypto.Cipher -import javax.crypto.spec.GCMParameterSpec -import javax.crypto.spec.SecretKeySpec - -object EncryptionUtils { - - init { - Security.addProvider(BouncyCastleProvider()) - } - - fun encryptWithPublicKey(publicKey: ByteArray, data: ByteArray): ByteArray { - val keyFactory = java.security.KeyFactory.getInstance("RSA", "BC") - val publicKeySpec = java.security.spec.X509EncodedKeySpec(publicKey) - val publicKeyObj: PublicKey = keyFactory.generatePublic(publicKeySpec) - - val cipher = try { - Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding", "BC") - } catch (e: Exception) { - throw RuntimeException("Failed to initialize cipher", e) - } - - cipher.init(Cipher.ENCRYPT_MODE, publicKeyObj) - - return cipher.doFinal(data) - } - - fun decryptWithPrivateKey(privateKey: ByteArray, encryptedData: ByteArray): ByteArray { - val keyFactory = java.security.KeyFactory.getInstance("RSA", "BC") - val privateKeySpec = java.security.spec.PKCS8EncodedKeySpec(privateKey) - val privateKeyObj: PrivateKey = keyFactory.generatePrivate(privateKeySpec) - - val cipher = try { - Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding", "BC") - } catch (e: Exception) { - throw RuntimeException("Failed to initialize cipher", e) - } - - cipher.init(Cipher.DECRYPT_MODE, privateKeyObj) - - return cipher.doFinal(encryptedData) - } -} - -/** - * A production-ready example of ephemeral ECDH encryption ("ECIES") with: - * - Curve25519 for ECDH, - * - HKDF for key derivation, - * - AES/GCM for authenticated encryption. - * - * Data layout = [ ephemeralPub(32|33 bytes) || iv(12 bytes) || ciphertext+authTag(...) ]. - */ -object CryptoUtils { - - private const val KEY_LENGTH_BYTES = 32 // 256-bit AES key - private const val IV_LENGTH_BYTES = 12 // 96-bit GCM IV - private const val GCM_TAG_BITS = 128 - - private val secureRandom = SecureRandom() - - /** - * Encrypts [plaintext] for [recipientPub] using ephemeral ECDH -> HKDF -> AES/GCM. - * Returns a byte array containing: - * ephemeralPub + iv + AES-GCM ciphertext (which includes auth tag). - */ - fun encryptForPublicKey(plaintext: ByteArray, recipientPub: ECPublicKey): ByteArray { - // 1) Generate ephemeral key pair - val ephemeralPair = Curve.generateKeyPair() // Curve25519 - val ephemeralPubBytes = ephemeralPair.publicKey.serialize() // 32 or 33 bytes - - // 2) Compute ECDH shared secret - val sharedSecret = Curve.calculateAgreement(recipientPub, ephemeralPair.privateKey) - // 3) Derive AES key with HKDF - val derivedAesKey = hkdfDeriveKey(sharedSecret, "ECDH-Encryption") - - // 4) AES/GCM encrypt - val iv = ByteArray(IV_LENGTH_BYTES).also { secureRandom.nextBytes(it) } - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(derivedAesKey, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) - val cipherText = cipher.doFinal(plaintext) - - // 5) Build final output: ephemeralPub + iv + ciphertext - return ByteBuffer.allocate(ephemeralPubBytes.size + iv.size + cipherText.size) - .put(ephemeralPubBytes) - .put(iv) - .put(cipherText) - .array() - } - - /** - * Decrypts [ciphertext] with our Curve25519 [myPrivate] key. - * Expects the layout: ephemeralPub(32|33) + iv(12) + actualCiphertext. - */ - fun decryptWithPrivateKey(ciphertext: ByteArray, myPrivate: ECPrivateKey): ByteArray { - // 1) Parse ephemeralPub from the front - // Typically 32 bytes for Curve25519. Some libs produce 33. Check your library's format. - val ephemeralPubSize = getEphemeralPubLength(ciphertext) - val ephemeralPubBytes = ciphertext.sliceArray(0 until ephemeralPubSize) - val ephemeralPub = Curve.decodePoint(ephemeralPubBytes, 0) as ECPublicKey - - // 2) Next 12 bytes = IV - val ivStart = ephemeralPubSize - val ivEnd = ivStart + IV_LENGTH_BYTES - val iv = ciphertext.sliceArray(ivStart until ivEnd) - - // 3) The rest is the GCM ciphertext+authTag - val actualCipherBytes = ciphertext.sliceArray(ivEnd until ciphertext.size) - - // 4) ECDH - val sharedSecret = Curve.calculateAgreement(ephemeralPub, myPrivate) - val derivedAesKey = hkdfDeriveKey(sharedSecret, "ECDH-Encryption") - - // 5) AES/GCM decrypt - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(derivedAesKey, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) - return cipher.doFinal(actualCipherBytes) - } - - /** - * Derive a 32-byte key from [sharedSecret] + optional [info] using HKDF (SHA-256). - */ - private fun hkdfDeriveKey(sharedSecret: ByteArray, info: String): ByteArray { - // salt = null => effectively empty - // We use HKDFv3 from libsignal-protocol - return HKDF.deriveSecrets( - sharedSecret, - ByteArray(0), // no salt - info.toByteArray(), // info context - KEY_LENGTH_BYTES // 32-byte key - ) - } - - /** - * Attempt to detect ephemeralPub length. Typically 32 for Curve25519. - * If your ephemeral keys might be 33, adjust logic as needed. - */ - private fun getEphemeralPubLength(ciphertext: ByteArray): Int { - if (ciphertext.size < 32 + 12) { - throw InvalidCipherTextException("Ciphertext too short to contain ephemeralPub + IV") - } - return 32 - } -} - -/** - * Demonstrates ephemeral ECDH encryption ("ECIES") with ephemeral Curve25519 keys - * for **each message**, providing forward secrecy in a simplified manner. - * - * Layout of the resulting ciphertext: - * [ ephemeralPub(32 bytes) || iv(12 bytes) || AES/GCM ciphertext+tag(...) ]. - * - * 1) For each message: - * - Generate ephemeral key pair (sender). - * - ECDH with recipient’s long-term public key => sharedSecret. - * - HKDF => AES key. - * - AES/GCM => final ciphertext. - * 2) Recipient uses ephemeralPub + their private key to derive the same AES key. - */ -object EphemeralECDHUtils { - - private const val AES_KEY_SIZE = 32 // 256-bit AES key - private const val IV_SIZE = 12 // 96-bit GCM IV - private const val GCM_TAG_BITS = 128 - private val secureRandom = SecureRandom() - - /** - * Encrypt [plaintext] using ephemeral Curve25519 for each message. - * [recipientPub] is the recipient's **long-term** public key. - */ - fun encrypt(plaintext: ByteArray, recipientPub: ECPublicKey): ByteArray { - // 1) Generate ephemeral key pair - val ephemeralPair = Curve.generateKeyPair() // ephemeral keys - val ephemeralPubBytes = ephemeralPair.publicKey.serialize() // 32 bytes - - // 2) ECDH => shared secret - val sharedSecret = Curve.calculateAgreement(recipientPub, ephemeralPair.privateKey) - - // 3) HKDF => 256-bit AES key - val aesKey = deriveAesKey(sharedSecret, "Ephemeral-FS") - - // 4) AES/GCM encrypt - val iv = ByteArray(IV_SIZE).also { secureRandom.nextBytes(it) } - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(aesKey, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) - val ciphertext = cipher.doFinal(plaintext) - - // 5) Combine ephemeralPub + iv + ciphertext - return ByteBuffer.allocate(ephemeralPubBytes.size + iv.size + ciphertext.size) - .put(ephemeralPubBytes) - .put(iv) - .put(ciphertext) - .array() - } - - /** - * Decrypt using recipient's **private** key + ephemeral pub from the ciphertext. - */ - fun decrypt(ciphertext: ByteArray, recipientPrivate: ECPrivateKey): ByteArray { - if (ciphertext.size < 32 + IV_SIZE) { - throw IllegalArgumentException("Ciphertext too small. Expected at least 44 bytes.") - } - - Timber.e("Decrypting ciphertext of size: ${ciphertext.size}") - // 1) Extract ephemeralPub(32 bytes) from the front - val ephemeralPubBytes = ciphertext.sliceArray(0 until 32) - val ephemeralPub = Curve.decodePoint(ephemeralPubBytes, 0) - - // 2) Next 12 bytes => IV - val ivStart = 32 - val ivEnd = ivStart + IV_SIZE - val iv = ciphertext.sliceArray(ivStart until ivEnd) - - // 3) Remainder => GCM ciphertext - val actualCipher = ciphertext.sliceArray(ivEnd until ciphertext.size) - - // 4) ECDH => sharedSecret - val sharedSecret = Curve.calculateAgreement(ephemeralPub, recipientPrivate) - - // 5) HKDF => AES key - val aesKey = deriveAesKey(sharedSecret, "Ephemeral-FS") - - // 6) AES/GCM decrypt - val cipher = Cipher.getInstance("AES/GCM/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), GCMParameterSpec(GCM_TAG_BITS, iv)) - return cipher.doFinal(actualCipher) - } - - private fun deriveAesKey(sharedSecret: ByteArray, info: String): ByteArray { - // Use org.signal.libsignal.protocol.kdf.HKDF (SHA-256 based) - // No salt => pass ByteArray(0) - return HKDF.deriveSecrets( - sharedSecret, - ByteArray(0), - info.toByteArray(), - AES_KEY_SIZE - ) - } -} 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 new file mode 100644 index 00000000..32ff02be --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/utils/EphemeralECDHUtils.kt @@ -0,0 +1,144 @@ +package com.canopas.yourspace.data.utils + +import com.canopas.yourspace.data.models.space.EncryptedDistribution +import com.google.firebase.firestore.Blob +import org.signal.libsignal.protocol.InvalidKeyException +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.protocol.ecc.ECKeyPair +import org.signal.libsignal.protocol.ecc.ECPrivateKey +import org.signal.libsignal.protocol.ecc.ECPublicKey +import org.signal.libsignal.protocol.util.ByteUtil +import timber.log.Timber +import java.nio.charset.Charset +import java.security.GeneralSecurityException +import java.security.MessageDigest +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * Utility class for encryption and decryption using Ephemeral Elliptic Curve Diffie-Hellman (ECDH) mechanism. + * This class provides methods to encrypt and decrypt data securely using a recipient's public key + * and an ephemeral key pair. + */ +object EphemeralECDHUtils { + + private const val SYNTHETIC_IV_LENGTH = 16 // Length of the synthetic initialization vector (IV). + + /** + * Encrypts the provided plaintext for a specific recipient using their public key. + * + * @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. + */ + fun encrypt( + receiverId: String, + plaintext: ByteArray, + receiverPub: ECPublicKey + ): EncryptedDistribution { + val ephemeralKeyPair: ECKeyPair = Curve.generateKeyPair() + val masterSecret: ByteArray = Curve.calculateAgreement(receiverPub, ephemeralKeyPair.privateKey) + val syntheticIv: ByteArray = computeSyntheticIv(masterSecret, plaintext) + val cipherKey: ByteArray = computeCipherKey(masterSecret, syntheticIv) + + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16))) + val cipherText = cipher.doFinal(plaintext) + + return EncryptedDistribution( + recipientId = receiverId, + ephemeralPub = Blob.fromBytes(ephemeralKeyPair.publicKey.serialize()), + iv = Blob.fromBytes(syntheticIv), + ciphertext = Blob.fromBytes(cipherText) + ) + } + + /** + * Decrypts an encrypted message using the recipient's private key. + * + * @param message The encrypted distribution containing ciphertext and metadata. + * @param receiverPrivateKey The receiver's private key. + * @return ByteArray? The decrypted plaintext or null if decryption fails. + */ + fun decrypt( + message: EncryptedDistribution, + receiverPrivateKey: ECPrivateKey + ): ByteArray? { + return try { + val syntheticIv = message.iv.toBytes() + val cipherText = message.ciphertext.toBytes() + val ephemeralPublic = Curve.decodePoint(message.ephemeralPub.toBytes(), 0) + val masterSecret = Curve.calculateAgreement(ephemeralPublic, receiverPrivateKey) + + val mac = Mac.getInstance("HmacSHA256") + mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) + val cipherKeyPart1 = mac.doFinal("cipher".toByteArray()) + + mac.init(SecretKeySpec(cipherKeyPart1, "HmacSHA256")) + val cipherKey = mac.doFinal(syntheticIv) + + val cipher = Cipher.getInstance("AES/CTR/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16))) + val plaintext = cipher.doFinal(cipherText) + + mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) + val verificationPart1 = mac.doFinal("auth".toByteArray()) + + mac.init(SecretKeySpec(verificationPart1, "HmacSHA256")) + val verificationPart2 = mac.doFinal(plaintext) + val ourSyntheticIv = ByteUtil.trim(verificationPart2, 16) + + if (!MessageDigest.isEqual(ourSyntheticIv, syntheticIv)) { + throw GeneralSecurityException("The computed syntheticIv didn't match the actual syntheticIv.") + } + plaintext + } catch (e: GeneralSecurityException) { + Timber.e(e, "Error while decrypting EphemeralCipherMessage") + null + } catch (e: InvalidKeyException) { + Timber.e(e, "Error while decrypting EphemeralCipherMessage") + null + } + } + + /** + * Computes a synthetic IV using the master secret and plaintext. + * + * @param masterSecret The shared master secret. + * @param plaintext The plaintext data. + * @return ByteArray The computed synthetic IV. + */ + private fun computeSyntheticIv(masterSecret: ByteArray, plaintext: ByteArray): ByteArray { + val input = "auth".toByteArray(Charset.forName("UTF-8")) + + val keyMac = Mac.getInstance("HmacSHA256") + keyMac.init(SecretKeySpec(masterSecret, "HmacSHA256")) + val syntheticIvKey: ByteArray = keyMac.doFinal(input) + + val ivMac = Mac.getInstance("HmacSHA256") + ivMac.init(SecretKeySpec(syntheticIvKey, "HmacSHA256")) + return ivMac.doFinal(plaintext).sliceArray(0 until SYNTHETIC_IV_LENGTH) + } + + /** + * Computes the cipher key using the master secret and synthetic IV. + * + * @param masterSecret The shared master secret. + * @param syntheticIv The synthetic IV. + * @return ByteArray The computed cipher key. + */ + private fun computeCipherKey(masterSecret: ByteArray, syntheticIv: ByteArray): ByteArray { + val input = "cipher".toByteArray(Charset.forName("UTF-8")) + + val keyMac = Mac.getInstance("HmacSHA256") + keyMac.init(SecretKeySpec(masterSecret, "HmacSHA256")) + val cipherKeyKey: ByteArray = keyMac.doFinal(input) + + val cipherMac = Mac.getInstance("HmacSHA256") + cipherMac.init(SecretKeySpec(cipherKeyKey, "HmacSHA256")) + return cipherMac.doFinal(syntheticIv) + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt b/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt index ca3ab30f..f5ef5fbf 100644 --- a/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt +++ b/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt @@ -6,6 +6,7 @@ import org.signal.libsignal.protocol.ecc.Curve import org.signal.libsignal.protocol.state.PreKeyRecord import org.signal.libsignal.protocol.state.SignedPreKeyRecord import org.signal.libsignal.protocol.util.KeyHelper +import org.signal.libsignal.zkgroup.profiles.ProfileKey import java.security.SecureRandom private const val INTEGER_MAX = 0x7fffffff @@ -17,6 +18,10 @@ object KeyHelper { return IdentityKeyPair(publicKey, keyPair.privateKey) } + fun getProfileKey(): ProfileKey { + return ProfileKey(SecureRandom().generateSeed(32)) + } + fun generateRegistrationId(extendedRange: Boolean): Int { return KeyHelper.generateRegistrationId(extendedRange) } From fc755edada72fb689bb6672282a4c27b5df56251 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Fri, 27 Dec 2024 16:00:11 +0530 Subject: [PATCH 07/30] WIP - profile key can be removed --- .../yourspace/data/models/space/ApiSpace.kt | 1 + .../yourspace/data/models/user/ApiUser.kt | 3 +- .../service/location/ApiLocationService.kt | 71 +++++++++++++++++-- .../data/service/user/ApiUserService.kt | 4 +- 4 files changed, 72 insertions(+), 7 deletions(-) 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 98666176..a525618a 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 @@ -25,6 +25,7 @@ data class ApiSpaceMember( val role: Int = SPACE_MEMBER_ROLE_MEMBER, val location_enabled: Boolean = true, val identity_key_public: Blob? = Blob.fromBytes(ByteArray(0)), + val profile_key: Blob? = Blob.fromBytes(ByteArray(0)), val created_at: Long? = System.currentTimeMillis() ) diff --git a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt index 9a617d4b..2cac86ba 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt @@ -27,7 +27,8 @@ data class ApiUser( val created_at: Long? = System.currentTimeMillis(), val updated_at: Long? = System.currentTimeMillis(), val identity_key_public: Blob? = Blob.fromBytes(ByteArray(0)), - val identity_key_private: Blob? = Blob.fromBytes(ByteArray(0)) + val identity_key_private: Blob? = Blob.fromBytes(ByteArray(0)), + val profile_key: Blob? = null, ) { @get:Exclude val fullName: String get() = "$first_name $last_name" 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 d22e25b3..b243b980 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 @@ -2,6 +2,8 @@ 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.SenderKeyDistribution import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore @@ -22,6 +24,7 @@ import org.signal.libsignal.protocol.groups.GroupCipher import org.signal.libsignal.protocol.groups.GroupSessionBuilder import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -55,7 +58,8 @@ class ApiLocationService @Inject constructor( ) { val lastLocation = locationManager.getLastLocation() ?: return userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val cipherAndDistributionMessage = getGroupCipherAndDistributionMessage(spaceId, userId) + val cipherAndDistributionMessage = + getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = true) val groupCipher = cipherAndDistributionMessage?.second ?: return val distributionMessage = cipherAndDistributionMessage.first Timber.e("XXXXXX: Distribution id: ${distributionMessage.distributionId}") @@ -90,7 +94,8 @@ class ApiLocationService @Inject constructor( recordedAt: Long ) { userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val cipherAndDistributionMessage = getGroupCipherAndDistributionMessage(spaceId, userId) + val cipherAndDistributionMessage = + getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = true) val groupCipher = cipherAndDistributionMessage?.second ?: return val distributionMessage = cipherAndDistributionMessage.first val lat = groupCipher.encrypt( @@ -151,15 +156,36 @@ class ApiLocationService @Inject constructor( private suspend fun getGroupCipherAndDistributionMessage( spaceId: String, - userId: String + userId: String, + canDistributeSenderKey: Boolean = false ): Pair? { val currentUser = userPreferences.currentUser ?: return null val senderKeyDistribution = spaceGroupKeysRef(spaceId) .document(userId).get().await().toObject(SenderKeyDistribution::class.java) + val distributions = senderKeyDistribution?.distributions ?: return null val currentUserDistribution = distributions.firstOrNull { it.recipientId == currentUser.id } - ?: return null - val currentUserPrivateKey = Curve.decodePrivatePoint(currentUser.identity_key_private?.toBytes()) + if (currentUserDistribution == null && canDistributeSenderKey) { + Timber.e( + "Sender key distribution not found for $userId in space $spaceId.\n+" + + "Can be a new member. Distributing sender key to new member..." + ) + distributeSenderKeyToNewMember( + spaceId, + currentUser.id, + ApiSpaceMember( + user_id = userId, + space_id = spaceId, + identity_key_public = currentUser.identity_key_public + ) + ) + getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = false) + } + if (currentUserDistribution == null) { + return null + } + val currentUserPrivateKey = + Curve.decodePrivatePoint(currentUser.identity_key_private?.toBytes()) val decryptedDistribution = EphemeralECDHUtils.decrypt(currentUserDistribution, currentUserPrivateKey) @@ -174,4 +200,39 @@ class ApiLocationService @Inject constructor( sessionBuilder.process(senderAddress, distributionMessage) return Pair(distributionMessage, receiverGroupCipher) } + + private suspend fun distributeSenderKeyToNewMember( + spaceId: String, + senderUserId: String, + newMember: ApiSpaceMember + ) { + val deviceId = userPreferences.currentUserSession?.device_id ?: "" + val deviceIdInt = deviceId.hashCode() and 0x7FFFFFFF + val groupAddress = SignalProtocolAddress(spaceId, deviceIdInt) + val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) + val distributionMessage = sessionBuilder.create(groupAddress, UUID.fromString(spaceId)) + val distributionBytes = distributionMessage.serialize() + val distributions = mutableListOf() + val publicBlob = newMember.identity_key_public ?: return + val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) + distributions.add( + EphemeralECDHUtils.encrypt( + newMember.user_id, + distributionBytes, + publicKey + ) + ) + + val docRef = spaceGroupKeysRef(spaceId).document(senderUserId) + + val data = SenderKeyDistribution( + senderId = senderUserId, + senderDeviceId = deviceIdInt, + distributions = distributions, + createdAt = System.currentTimeMillis() + ) + + docRef.set(data).await() + Timber.d("Sender key distribution uploaded for $senderUserId in space $spaceId.") + } } 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 84798342..6d3d2e74 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 @@ -77,6 +77,7 @@ class ApiUserService @Inject constructor( return Triple(false, savedUser, session) } else { val identityKeyPair = KeyHelper.generateIdentityKeyPair() + val profileKey = KeyHelper.getProfileKey() val user = ApiUser( id = uid!!, email = account?.email ?: firebaseUser?.email ?: "", @@ -87,7 +88,8 @@ class ApiUserService @Inject constructor( profile_image = account?.photoUrl?.toString() ?: firebaseUser?.photoUrl?.toString() ?: "", identity_key_public = Blob.fromBytes(identityKeyPair.publicKey.publicKey.serialize()), - identity_key_private = Blob.fromBytes(identityKeyPair.privateKey.serialize()) + identity_key_private = Blob.fromBytes(identityKeyPair.privateKey.serialize()), + profile_key = Blob.fromBytes(profileKey.serialize()), ) userRef.document(uid).set(user).await() val sessionDocRef = sessionRef(user.id).document() From 2192f6234c440f96752a87618722651241923739 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Fri, 27 Dec 2024 16:20:30 +0530 Subject: [PATCH 08/30] WIP - lint fix --- .../java/com/canopas/yourspace/data/models/user/ApiUser.kt | 2 +- .../yourspace/data/service/location/ApiLocationService.kt | 2 +- .../canopas/yourspace/data/service/space/ApiSpaceService.kt | 3 +-- .../com/canopas/yourspace/data/service/user/ApiUserService.kt | 2 +- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt index 2cac86ba..3fc791d7 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt @@ -28,7 +28,7 @@ data class ApiUser( val updated_at: Long? = System.currentTimeMillis(), val identity_key_public: Blob? = Blob.fromBytes(ByteArray(0)), val identity_key_private: Blob? = Blob.fromBytes(ByteArray(0)), - val profile_key: Blob? = null, + val profile_key: Blob? = null ) { @get:Exclude val fullName: String get() = "$first_name $last_name" 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 b243b980..606f2a7b 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 @@ -168,7 +168,7 @@ class ApiLocationService @Inject constructor( if (currentUserDistribution == null && canDistributeSenderKey) { Timber.e( "Sender key distribution not found for $userId in space $spaceId.\n+" + - "Can be a new member. Distributing sender key to new member..." + "Can be a new member. Distributing sender key to new member..." ) distributeSenderKeyToNewMember( spaceId, 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 eef8e42f..d70d5946 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 @@ -18,11 +18,10 @@ import com.canopas.yourspace.data.utils.EphemeralECDHUtils import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.tasks.await -import timber.log.Timber import org.signal.libsignal.protocol.SignalProtocolAddress import org.signal.libsignal.protocol.ecc.Curve import org.signal.libsignal.protocol.groups.GroupSessionBuilder -import org.signal.libsignal.protocol.groups.state.InMemorySenderKeyStore +import timber.log.Timber import java.util.UUID import javax.inject.Inject import javax.inject.Singleton 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 6d3d2e74..0ba35c07 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 @@ -89,7 +89,7 @@ class ApiUserService @Inject constructor( ?: "", identity_key_public = Blob.fromBytes(identityKeyPair.publicKey.publicKey.serialize()), identity_key_private = Blob.fromBytes(identityKeyPair.privateKey.serialize()), - profile_key = Blob.fromBytes(profileKey.serialize()), + profile_key = Blob.fromBytes(profileKey.serialize()) ) userRef.document(uid).set(user).await() val sessionDocRef = sessionRef(user.id).document() From 6d2e61f460c11cf5b521a7097f679af847d569df Mon Sep 17 00:00:00 2001 From: cp-megh Date: Tue, 31 Dec 2024 10:15:46 +0530 Subject: [PATCH 09/30] WIP - multidevice support --- app/build.gradle.kts | 4 + .../canopas/yourspace/YourSpaceApplication.kt | 2 +- .../com/canopas/yourspace/ui/MainActivity.kt | 8 ++ .../com/canopas/yourspace/ui/MainViewModel.kt | 11 ++ .../yourspace/ui/component/OtpTextField.kt | 2 +- .../ui/flow/auth/SignInMethodViewModel.kt | 25 ++-- .../ui/flow/onboard/OnboardViewModel.kt | 4 + .../ui/flow/pin/enterpin/EnterPinScreen.kt | 95 +++++++++++++++ .../ui/flow/pin/enterpin/EnterPinViewModel.kt | 88 ++++++++++++++ .../ui/flow/pin/setpin/SetPinScreen.kt | 95 +++++++++++++++ .../ui/flow/pin/setpin/SetPinViewModel.kt | 107 ++++++++++++++++ .../yourspace/ui/navigation/AppRoute.kt | 10 ++ .../yourspace/data/models/space/ApiSpace.kt | 1 - .../data/models/user/ApiSenderKeyRecord.kt | 17 +++ .../yourspace/data/models/user/ApiUser.kt | 2 +- .../data/service/auth/AuthService.kt | 13 ++ .../data/service/auth/FirebaseAuthService.kt | 7 -- .../service/location/ApiLocationService.kt | 54 ++++---- .../data/service/space/ApiSpaceService.kt | 15 +++ .../data/service/user/ApiUserService.kt | 76 ++++++++++-- .../yourspace/data/storage/UserPreferences.kt | 19 +++ .../BufferedSenderKeyStore.kt | 79 ++++++++---- .../canopas/yourspace/data/utils/Config.kt | 3 + .../yourspace/data/utils/PrivateKeyUtils.kt | 115 ++++++++++++++++++ 24 files changed, 771 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinScreen.kt create mode 100644 app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinViewModel.kt create mode 100644 app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinScreen.kt create mode 100644 app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/models/user/ApiSenderKeyRecord.kt create mode 100644 data/src/main/java/com/canopas/yourspace/data/utils/PrivateKeyUtils.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e4d18fb9..4c84d7bb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -212,5 +212,9 @@ dependencies { // Gson implementation("com.google.code.gson:gson:2.10.1") + // Signal Protocol + implementation("org.signal:libsignal-client:0.64.1") + implementation("org.signal:libsignal-android:0.64.1") + implementation(project(":data")) } diff --git a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt index b2edf095..260071a3 100644 --- a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt +++ b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt @@ -53,7 +53,7 @@ class YourSpaceApplication : super.onCreate() Timber.plant(Timber.DebugTree()) - FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = true + FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = false ProcessLifecycleOwner.get().lifecycle.addObserver(this) authService.addListener(this) setNotificationChannel() diff --git a/app/src/main/java/com/canopas/yourspace/ui/MainActivity.kt b/app/src/main/java/com/canopas/yourspace/ui/MainActivity.kt index fb6a1c13..47a17c85 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/MainActivity.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/MainActivity.kt @@ -47,6 +47,8 @@ import com.canopas.yourspace.ui.flow.messages.chat.MessagesScreen import com.canopas.yourspace.ui.flow.messages.thread.ThreadsScreen import com.canopas.yourspace.ui.flow.onboard.OnboardScreen import com.canopas.yourspace.ui.flow.permission.EnablePermissionsScreen +import com.canopas.yourspace.ui.flow.pin.enterpin.EnterPinScreen +import com.canopas.yourspace.ui.flow.pin.setpin.SetPinScreen import com.canopas.yourspace.ui.flow.settings.SettingsScreen import com.canopas.yourspace.ui.flow.settings.profile.EditProfileScreen import com.canopas.yourspace.ui.flow.settings.space.SpaceProfileScreen @@ -124,6 +126,12 @@ fun MainApp(viewModel: MainViewModel) { slideComposable(AppDestinations.signIn.path) { SignInMethodsScreen() } + slideComposable(AppDestinations.setPin.path) { + SetPinScreen() + } + slideComposable(AppDestinations.enterPin.path) { + EnterPinScreen() + } slideComposable(AppDestinations.home.path) { navController.currentBackStackEntry 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 022d55e3..28c866dc 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/MainViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/MainViewModel.kt @@ -42,9 +42,20 @@ class MainViewModel @Inject constructor( init { 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 initialRoute = when { !userPreferences.isIntroShown() -> AppDestinations.intro.path userPreferences.currentUser == null -> AppDestinations.signIn.path + showSetPinScreen -> AppDestinations.setPin.path + showEnterPinScreen -> AppDestinations.enterPin.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 ed1f4171..36e601c8 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 @@ -67,7 +67,7 @@ fun OtpInputField( repeat(digitCount) { index -> OTPDigit(index, pinText, textStyle, focusRequester, width = width) - if (index == 2) { + if (index == 2 && digitCount > 4) { HorizontalDivider( modifier = Modifier .width(16.dp) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/auth/SignInMethodViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/auth/SignInMethodViewModel.kt index 98e28221..a42a08da 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/auth/SignInMethodViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/auth/SignInMethodViewModel.kt @@ -42,12 +42,12 @@ class SignInMethodViewModel @Inject constructor( _state.emit(_state.value.copy(showGoogleLoading = true)) try { val firebaseToken = firebaseAuth.signInWithGoogleAuthCredential(account.idToken) - val isNewUser = authService.verifiedGoogleLogin( + authService.verifiedGoogleLogin( firebaseAuth.currentUserUid, firebaseToken, account ) - onSignUp(isNewUser) + onSignUp() _state.emit(_state.value.copy(showGoogleLoading = false)) } catch (e: Exception) { Timber.e(e, "Failed to sign in with google") @@ -65,7 +65,7 @@ class SignInMethodViewModel @Inject constructor( _state.emit(_state.value.copy(showAppleLoading = true)) try { val firebaseToken = authResult.user?.getIdToken(true)?.await() - val isNewUser = authService.verifiedAppleLogin( + authService.verifiedAppleLogin( firebaseAuth.currentUserUid, firebaseToken?.token ?: "", authResult.user ?: run { @@ -78,7 +78,7 @@ class SignInMethodViewModel @Inject constructor( return@launch } ) - onSignUp(isNewUser) + onSignUp() _state.emit(_state.value.copy(showAppleLoading = false)) } catch (e: Exception) { Timber.e(e, "Failed to sign in with Apple") @@ -95,17 +95,22 @@ class SignInMethodViewModel @Inject constructor( _state.value = _state.value.copy(error = null) } - private fun onSignUp(isNewUser: Boolean) = viewModelScope.launch(appDispatcher.MAIN) { - if (isNewUser) { + private fun onSignUp() = viewModelScope.launch(appDispatcher.MAIN) { + val currentUser = authService.currentUser ?: return@launch + val showSetPinScreen = currentUser.identity_key_public?.toBytes() + .contentEquals(currentUser.identity_key_private?.toBytes()) + val showEnterPinScreen = !showSetPinScreen && userPreferences.getPasskey() + .isNullOrEmpty() + + if (showSetPinScreen) { navigator.navigateTo( - AppDestinations.onboard.path, + AppDestinations.setPin.path, popUpToRoute = AppDestinations.signIn.path, inclusive = true ) - } else { - userPreferences.setOnboardShown(true) + } else if (showEnterPinScreen) { navigator.navigateTo( - AppDestinations.home.path, + AppDestinations.enterPin.path, popUpToRoute = AppDestinations.signIn.path, inclusive = true ) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/onboard/OnboardViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/onboard/OnboardViewModel.kt index 8a3e6c61..dae356aa 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/onboard/OnboardViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/onboard/OnboardViewModel.kt @@ -36,6 +36,10 @@ class OnboardViewModel @Inject constructor( private val currentUser get() = authService.currentUser init { + Timber.e( + "XXXXXX:\n" + + "identity_key_public: ${userPreferences.currentUser?.identity_key_public?.toBytes()?.size}\n" + ) checkInternetConnection() val user = authService.currentUser _state.value = _state.value.copy( 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 new file mode 100644 index 00000000..666c251c --- /dev/null +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinScreen.kt @@ -0,0 +1,95 @@ +package com.canopas.yourspace.ui.flow.pin.enterpin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.canopas.yourspace.ui.component.OtpInputField + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EnterPinScreen() { + Scaffold( + topBar = { + TopAppBar(title = { Text("Enter Your PIN") }) + } + ) { + EnterPinContent(modifier = Modifier.padding(it)) + } +} + +@Composable +private fun EnterPinContent(modifier: Modifier) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + Column( + modifier = modifier + .padding(32.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Please enter your 4-digit PIN to access your account", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + textAlign = TextAlign.Center + ) + + Text( + text = "Your PIN ensures that only you can access your account", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + textAlign = TextAlign.Center + ) + + OtpInputField( + pinText = state.pin, + onPinTextChange = { viewModel.onPinChanged(it) }, + digitCount = 4 + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (state.pinError != null) { + Text( + text = state.pinError!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + viewModel.processPin() + }, + enabled = state.pin != "" && state.pinError == "", + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + Text("Continue") + } + } +} 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 new file mode 100644 index 00000000..cbe8d661 --- /dev/null +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/enterpin/EnterPinViewModel.kt @@ -0,0 +1,88 @@ +package com.canopas.yourspace.ui.flow.pin.enterpin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.navigation.AppDestinations +import com.canopas.yourspace.ui.navigation.AppNavigator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class EnterPinViewModel @Inject constructor( + private val navigator: AppNavigator, + private val authService: AuthService, + private val appDispatcher: AppDispatcher, + private val userPreferences: UserPreferences, + private val connectivityObserver: ConnectivityObserver +) : ViewModel() { + private val _state = MutableStateFlow(EnterPinScreenState()) + val state: StateFlow = _state + + init { + checkInternetConnection() + } + + fun onPinChanged(newPin: String) { + _state.value = _state.value.copy(pin = newPin) + validatePin(newPin) + } + + private fun validatePin(newPin: String) { + _state.value = _state.value.copy( + pinError = + when { + newPin.length < 4 -> "Pin must be at least 4 characters" + !newPin.all { it.isDigit() } -> "PIN must contain only digits" + else -> "" + } + ) + } + + fun checkInternetConnection() { + viewModelScope.launch(appDispatcher.IO) { + connectivityObserver.observe().collectLatest { status -> + _state.emit( + _state.value.copy( + connectivityStatus = status + ) + ) + } + } + } + + fun processPin() = viewModelScope.launch(appDispatcher.IO) { + val pin = state.value.pin + if (pin.length == 4) { + 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 = "Invalid Pin") + } + } else { + _state.value = _state.value.copy(pinError = "Pin must be 4 characters") + } + } +} + +data class EnterPinScreenState( + val showLoader: Boolean = false, + val pin: String = "", + val confirmPin: String = "", + val pinError: String? = null, + val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available, + val error: Exception? = null +) 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 new file mode 100644 index 00000000..b27239ec --- /dev/null +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinScreen.kt @@ -0,0 +1,95 @@ +package com.canopas.yourspace.ui.flow.pin.setpin + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.canopas.yourspace.ui.component.OtpInputField + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SetPinScreen() { + Scaffold( + topBar = { + TopAppBar(title = { Text("Set Your PIN") }) + } + ) { + SetPinContent(modifier = Modifier.padding(it)) + } +} + +@Composable +private fun SetPinContent(modifier: Modifier) { + val viewModel = hiltViewModel() + val state by viewModel.state.collectAsState() + + Column( + modifier = modifier + .padding(32.dp) + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Secure your account by setting a 4-digit PIN", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + textAlign = TextAlign.Center + ) + + Text( + text = "Your PIN ensures that only you can access your account", + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + textAlign = TextAlign.Center + ) + + OtpInputField( + pinText = state.pin, + onPinTextChange = { viewModel.onPinChanged(it) }, + digitCount = 4 + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (state.pinError != null) { + Text( + text = state.pinError!!, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) + } + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + onClick = { + viewModel.processPin() + }, + enabled = state.pin != "" && state.pinError == "", + modifier = Modifier + .fillMaxWidth() + .height(50.dp) + ) { + Text("Set Pin") + } + } +} 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 new file mode 100644 index 00000000..55bac9b9 --- /dev/null +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt @@ -0,0 +1,107 @@ +package com.canopas.yourspace.ui.flow.pin.setpin + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.canopas.yourspace.data.repository.SpaceRepository +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.navigation.AppDestinations +import com.canopas.yourspace.ui.navigation.AppNavigator +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class SetPinViewModel @Inject constructor( + private val navigator: AppNavigator, + private val authService: AuthService, + private val appDispatcher: AppDispatcher, + private val spaceRepository: SpaceRepository, + private val userPreferences: UserPreferences, + private val connectivityObserver: ConnectivityObserver +) : ViewModel() { + private val _state = MutableStateFlow(EnterPinScreenState()) + val state: StateFlow = _state + + init { + checkInternetConnection() + } + + fun onPinChanged(newPin: String) { + _state.value = _state.value.copy(pin = newPin) + if (newPin.length == 4) { + _state.value = _state.value.copy(pinError = "") + } + } + + private fun validatePin(newPin: String) { + _state.value = _state.value.copy( + pinError = + when { + newPin.length < 4 -> "Pin must be at least 4 characters" + !newPin.all { it.isDigit() } -> "PIN must contain only digits" + else -> "" + } + ) + } + + fun checkInternetConnection() { + viewModelScope.launch(appDispatcher.IO) { + connectivityObserver.observe().collectLatest { status -> + _state.emit( + _state.value.copy( + connectivityStatus = status + ) + ) + } + } + } + + fun processPin() = viewModelScope.launch(appDispatcher.MAIN) { + _state.value = _state.value.copy(showLoader = true) + val pin = state.value.pin + validatePin(pin) + if (pin.length == 4) { + authService.generateAndSaveUserKeys(passKey = pin) + val userId = authService.getUser()?.id + val userHasSpaces = userId?.let { + val flowList = spaceRepository.getUserSpaces(it) + Timber.e("XXXXXX: Flowlist - ${flowList.firstOrNull()}") + flowList.firstOrNull()?.isNotEmpty() ?: false + } + Timber.e("XXXXXX: User has spaces - $userHasSpaces") + if (userHasSpaces == false) { + navigator.navigateTo( + AppDestinations.onboard.path, + popUpToRoute = AppDestinations.signIn.path, + inclusive = true + ) + } else { + userPreferences.setOnboardShown(true) + navigator.navigateTo( + AppDestinations.home.path, + popUpToRoute = AppDestinations.signIn.path, + inclusive = true + ) + } + } else { + _state.value = _state.value.copy(pinError = "Pin must be 4 characters") + } + } +} + +data class EnterPinScreenState( + val showLoader: Boolean = false, + val pin: String = "", + val confirmPin: String = "", + val pinError: String? = null, + val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available, + val error: Exception? = null +) diff --git a/app/src/main/java/com/canopas/yourspace/ui/navigation/AppRoute.kt b/app/src/main/java/com/canopas/yourspace/ui/navigation/AppRoute.kt index 9de649c2..dba524b5 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/navigation/AppRoute.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/navigation/AppRoute.kt @@ -86,6 +86,16 @@ object AppDestinations { override val path: String = "sign-in" } + val setPin = object : AppRoute { + override val arguments: List = emptyList() + override val path: String = "set-pin" + } + + val enterPin = object : AppRoute { + override val arguments: List = emptyList() + override val path: String = "enter-pin" + } + val map = object : AppRoute { override val arguments: List = emptyList() override val path: String = "map" 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 a525618a..98666176 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 @@ -25,7 +25,6 @@ data class ApiSpaceMember( val role: Int = SPACE_MEMBER_ROLE_MEMBER, val location_enabled: Boolean = true, val identity_key_public: Blob? = Blob.fromBytes(ByteArray(0)), - val profile_key: Blob? = Blob.fromBytes(ByteArray(0)), val created_at: Long? = System.currentTimeMillis() ) 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 new file mode 100644 index 00000000..64fbbeff --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiSenderKeyRecord.kt @@ -0,0 +1,17 @@ +package com.canopas.yourspace.data.models.user + +import androidx.annotation.Keep +import com.google.firebase.firestore.Blob +import com.squareup.moshi.JsonClass +import java.util.UUID + +@Keep +@JsonClass(generateAdapter = true) +data class ApiSenderKeyRecord( + val id: String = UUID.randomUUID().toString(), + val address: String = "", + val deviceId: Int = 0, + val distributionId: String = "", + val record: Blob = Blob.fromBytes(ByteArray(0)), + val createdAt: Long = System.currentTimeMillis() +) diff --git a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt index 3fc791d7..60480b7c 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/user/ApiUser.kt @@ -28,7 +28,7 @@ data class ApiUser( val updated_at: Long? = System.currentTimeMillis(), val identity_key_public: Blob? = Blob.fromBytes(ByteArray(0)), val identity_key_private: Blob? = Blob.fromBytes(ByteArray(0)), - val profile_key: Blob? = null + val identity_key_salt: Blob? = Blob.fromBytes(ByteArray(0)) // Salt for key derivation ) { @get:Exclude val fullName: String get() = "$first_name $last_name" 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 0923b2e2..8ad4d760 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 @@ -119,6 +119,19 @@ class AuthService @Inject constructor( signOut() } + suspend fun generateAndSaveUserKeys(passKey: String) { + currentUser?.id?.let { apiUserService.generateAndSaveUserKeys(it, passKey) } + } + + suspend fun validatePasskey(passKey: String): Boolean { + val user = currentUser ?: return false + val validationResult = apiUserService.validatePasskey(user, passKey) + if (validationResult != null) { + userPreferences.storePasskey(passKey) + } + return validationResult != null + } + suspend fun getUser(): ApiUser? = apiUserService.getUser(currentUser?.id ?: "") suspend fun getUserFlow() = apiUserService.getUserFlow(currentUser?.id ?: "") diff --git a/data/src/main/java/com/canopas/yourspace/data/service/auth/FirebaseAuthService.kt b/data/src/main/java/com/canopas/yourspace/data/service/auth/FirebaseAuthService.kt index b92fa544..92d0d956 100644 --- a/data/src/main/java/com/canopas/yourspace/data/service/auth/FirebaseAuthService.kt +++ b/data/src/main/java/com/canopas/yourspace/data/service/auth/FirebaseAuthService.kt @@ -2,7 +2,6 @@ package com.canopas.yourspace.data.service.auth import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.GoogleAuthProvider -import com.google.firebase.auth.PhoneAuthCredential import kotlinx.coroutines.tasks.await import javax.inject.Inject @@ -19,9 +18,3 @@ class FirebaseAuthService @Inject constructor( return result.user?.getIdToken(true)?.await()?.token ?: "" } } - -sealed class PhoneAuthState { - data class VerificationCompleted(val credential: PhoneAuthCredential) : PhoneAuthState() - data class VerificationFailed(val e: Exception) : PhoneAuthState() - data class CodeSent(val verificationId: String) : PhoneAuthState() -} 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 606f2a7b..6d7faa3a 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,7 +3,6 @@ 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.SenderKeyDistribution import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore @@ -13,6 +12,7 @@ import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBER import com.canopas.yourspace.data.utils.EphemeralECDHUtils import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.Blob +import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import kotlinx.coroutines.flow.Flow @@ -62,7 +62,6 @@ class ApiLocationService @Inject constructor( getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = true) val groupCipher = cipherAndDistributionMessage?.second ?: return val distributionMessage = cipherAndDistributionMessage.first - Timber.e("XXXXXX: Distribution id: ${distributionMessage.distributionId}") val lat = groupCipher.encrypt( distributionMessage.distributionId, lastLocation.latitude.toString().toByteArray(Charsets.UTF_8) @@ -72,7 +71,6 @@ class ApiLocationService @Inject constructor( lastLocation.longitude.toString().toByteArray(Charsets.UTF_8) ) - Timber.d("Last known location: $lastLocation\nLat: $lat\nLon: $lon") val docRef = spaceMemberLocationRef(spaceId, userId).document() val location = EncryptedApiLocation( @@ -107,6 +105,7 @@ class ApiLocationService @Inject constructor( longitude.toString().toByteArray(Charsets.UTF_8) ) + Timber.e("YYYYYY: LAt: $lat, Lon: $lon") val docRef = spaceMemberLocationRef(spaceId, userId).document() val location = EncryptedApiLocation( @@ -124,10 +123,12 @@ class ApiLocationService @Inject constructor( suspend fun getCurrentLocation(userId: String): Flow> { return flow { try { + Timber.e("YYYYYY: Here") val encryptedLocation = spaceMemberLocationRef(currentSpaceId, userId).whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING).limit(1) .snapshotFlow(EncryptedApiLocation::class.java) + Timber.e("YYYYYY: Here - encryptedLocation: $encryptedLocation") encryptedLocation.collect { encryptedLocationList -> val apiLocations = encryptedLocationList.map { encryptedLocation -> val receiverGroupCipher = @@ -146,6 +147,7 @@ class ApiLocationService @Inject constructor( created_at = encryptedLocation.created_at ) } + Timber.e("YYYYYY: Here - apiLocations: $apiLocations") emit(apiLocations) } } catch (e: Exception) { @@ -171,13 +173,14 @@ class ApiLocationService @Inject constructor( "Can be a new member. Distributing sender key to new member..." ) distributeSenderKeyToNewMember( - spaceId, - currentUser.id, - ApiSpaceMember( + spaceId = spaceId, + senderUserId = senderKeyDistribution.senderId, + newMember = ApiSpaceMember( user_id = userId, space_id = spaceId, identity_key_public = currentUser.identity_key_public - ) + ), + senderDeviceId = senderKeyDistribution.senderDeviceId ) getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = false) } @@ -195,6 +198,10 @@ class ApiLocationService @Inject constructor( spaceId, senderKeyDistribution.senderDeviceId ) + + // Load sender key for particular distributionId/space + bufferedSenderKeyStore.loadSenderKey(senderAddress, distributionMessage.distributionId) + val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) val receiverGroupCipher = GroupCipher(bufferedSenderKeyStore, senderAddress) sessionBuilder.process(senderAddress, distributionMessage) @@ -204,35 +211,26 @@ class ApiLocationService @Inject constructor( private suspend fun distributeSenderKeyToNewMember( spaceId: String, senderUserId: String, - newMember: ApiSpaceMember + newMember: ApiSpaceMember, + senderDeviceId: Int ) { - val deviceId = userPreferences.currentUserSession?.device_id ?: "" - val deviceIdInt = deviceId.hashCode() and 0x7FFFFFFF - val groupAddress = SignalProtocolAddress(spaceId, deviceIdInt) + val groupAddress = SignalProtocolAddress(spaceId, senderDeviceId) val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) val distributionMessage = sessionBuilder.create(groupAddress, UUID.fromString(spaceId)) val distributionBytes = distributionMessage.serialize() - val distributions = mutableListOf() val publicBlob = newMember.identity_key_public ?: return val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) - distributions.add( - EphemeralECDHUtils.encrypt( - newMember.user_id, - distributionBytes, - publicKey - ) - ) - val docRef = spaceGroupKeysRef(spaceId).document(senderUserId) - val data = SenderKeyDistribution( - senderId = senderUserId, - senderDeviceId = deviceIdInt, - distributions = distributions, - createdAt = System.currentTimeMillis() + val distribution = EphemeralECDHUtils.encrypt( + newMember.user_id, + distributionBytes, + publicKey ) - - docRef.set(data).await() - Timber.d("Sender key distribution uploaded for $senderUserId in space $spaceId.") + docRef.update( + "distributions", + FieldValue.arrayUnion(distribution) + ).await() + Timber.d("Sender key distribution uploaded for new member: ${newMember.user_id} for sender $senderUserId.") } } 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 d70d5946..7627c045 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 @@ -145,6 +145,21 @@ class ApiSpaceService @Inject constructor( db.collectionGroup(FIRESTORE_COLLECTION_SPACE_MEMBERS).whereEqualTo("user_id", userId) .snapshotFlow(ApiSpaceMember::class.java) + suspend fun doesUserHaveAnySpace(userId: String): Boolean { + return try { + val querySnapshot = db.collectionGroup(FIRESTORE_COLLECTION_SPACE_MEMBERS) + .whereEqualTo("user_id", userId) + .limit(1) + .get() + .await() + + querySnapshot.documents.isNotEmpty() + } catch (e: Exception) { + Timber.e(e, "Error checking if user is part of any space") + false + } + } + fun getMemberBySpaceId(spaceId: String) = spaceMemberRef(spaceId).snapshotFlow(ApiSpaceMember::class.java) 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 0ba35c07..34ccf0eb 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 @@ -5,10 +5,13 @@ import com.canopas.yourspace.data.models.user.ApiUserSession import com.canopas.yourspace.data.models.user.LOGIN_TYPE_APPLE import com.canopas.yourspace.data.models.user.LOGIN_TYPE_GOOGLE import com.canopas.yourspace.data.service.location.ApiLocationService +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.KeyHelper +import com.canopas.yourspace.data.utils.PrivateKeyUtils import com.canopas.yourspace.data.utils.snapshotFlow import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.firebase.auth.FirebaseUser @@ -17,12 +20,14 @@ import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.functions.FirebaseFunctions import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton +import kotlin.random.Random const val NETWORK_STATUS_CHECK_INTERVAL = 3 * 60 * 1000 @@ -31,8 +36,11 @@ class ApiUserService @Inject constructor( db: FirebaseFirestore, private val device: Device, private val locationService: ApiLocationService, - private val functions: FirebaseFunctions + private val functions: FirebaseFunctions, + userPreferences: UserPreferences, + private val privateKeyUtils: PrivateKeyUtils ) { + private val currentUser = userPreferences.currentUser private val userRef = db.collection(FIRESTORE_COLLECTION_USERS) private fun sessionRef(userId: String) = userRef.document(userId.takeIf { it.isNotBlank() } ?: "null") @@ -40,7 +48,11 @@ class ApiUserService @Inject constructor( suspend fun getUser(userId: String): ApiUser? { return try { - userRef.document(userId).get().await().toObject(ApiUser::class.java) + userRef.document(userId).get().await().toObject(ApiUser::class.java)?.let { user -> + if (user.id != currentUser?.id) return user + val decryptedPrivateKey = decryptPrivateKey(user) ?: return@let user + user.copy(identity_key_private = Blob.fromBytes(decryptedPrivateKey)) + } } catch (e: Exception) { Timber.e(e, "Error while getting user") null @@ -48,7 +60,11 @@ class ApiUserService @Inject constructor( } fun getUserFlow(userId: String) = - userRef.document(userId).snapshotFlow(ApiUser::class.java) + userRef.document(userId).snapshotFlow(ApiUser::class.java).map { user -> + user?.let { decryptPrivateKey(it) }?.let { decryptedKey -> + user.copy(identity_key_private = Blob.fromBytes(decryptedKey)) + } + } fun getUserSessionFlow(userId: String, sessionId: String) = sessionRef(userId).document(sessionId).snapshotFlow(ApiUserSession::class.java) @@ -76,8 +92,6 @@ class ApiUserService @Inject constructor( sessionDocRef.set(session).await() return Triple(false, savedUser, session) } else { - val identityKeyPair = KeyHelper.generateIdentityKeyPair() - val profileKey = KeyHelper.getProfileKey() val user = ApiUser( id = uid!!, email = account?.email ?: firebaseUser?.email ?: "", @@ -86,10 +100,7 @@ class ApiUserService @Inject constructor( last_name = account?.familyName ?: "", provider_firebase_id_token = firebaseToken, profile_image = account?.photoUrl?.toString() ?: firebaseUser?.photoUrl?.toString() - ?: "", - identity_key_public = Blob.fromBytes(identityKeyPair.publicKey.publicKey.serialize()), - identity_key_private = Blob.fromBytes(identityKeyPair.privateKey.serialize()), - profile_key = Blob.fromBytes(profileKey.serialize()) + ?: "" ) userRef.document(uid).set(user).await() val sessionDocRef = sessionRef(user.id).document() @@ -107,6 +118,53 @@ class ApiUserService @Inject constructor( } } + suspend fun saveSenderKeyRecord(address: String, deviceId: Int, distributionId: String) { + } + + suspend fun generateAndSaveUserKeys(userId: String, passKey: String) { + val identityKeyPair = KeyHelper.generateIdentityKeyPair() + val salt = ByteArray(16).apply { Random.nextBytes(this) } + val encryptedPrivateKey = privateKeyUtils.encryptPrivateKey( + identityKeyPair.privateKey.serialize(), + passkey = passKey, + salt = salt + ) + userRef.document(userId).update( + mapOf( + "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() + } + + fun validatePasskey(user: ApiUser, passKey: String): ByteArray? { + val decryptedPrivateKey = decryptPrivateKey(user, passKey) + return if (decryptedPrivateKey != null) { + decryptedPrivateKey + } else { + Timber.e("Failed to validate passkey for user ${user.id}") + null + } + } + + /** + * Decrypts the private key using the stored passkey/PIN and salt from ApiUser. + * Returns the decrypted private key as ByteArray. + */ + private fun decryptPrivateKey(user: ApiUser, pin: String? = null): ByteArray? { + val encryptedPrivateKey = user.identity_key_private?.toBytes() ?: return null + val salt = user.identity_key_salt?.toBytes() ?: return null + return try { + val decrypted = privateKeyUtils.decryptPrivateKey(encryptedPrivateKey, salt, pin) + Timber.e("XXXXXX: Encrypted Private Key: $encryptedPrivateKey\nDecrypted: $decrypted") + decrypted + } catch (e: EncryptionException) { + Timber.e(e, "Failed to decrypt private key for user ${user.id}") + null + } + } + private suspend fun deactivateOldSessions(userId: String) { sessionRef(userId).whereEqualTo("session_active", true).get().await().documents.forEach { it.reference.update("session_active", false).await() diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt b/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt index 623c8711..931ad553 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt @@ -10,6 +10,7 @@ import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.models.user.ApiUserSession import com.canopas.yourspace.data.storage.UserPreferences.PreferencesKey.KEY_USER_CURRENT_SPACE import com.canopas.yourspace.data.storage.UserPreferences.PreferencesKey.KEY_USER_JSON +import com.canopas.yourspace.data.storage.UserPreferences.PreferencesKey.KEY_USER_PASSKEY import com.canopas.yourspace.data.storage.UserPreferences.PreferencesKey.KEY_USER_SESSION_JSON import com.google.firebase.firestore.Blob import com.squareup.moshi.FromJson @@ -49,6 +50,8 @@ class UserPreferences @Inject constructor( val LAST_BATTERY_DIALOG_DATE = stringPreferencesKey("last_battery_dialog_date") val KEY_USER_MAP_STYLE = stringPreferencesKey("user_map_style") + + val KEY_USER_PASSKEY = stringPreferencesKey("user_passkey") } suspend fun isIntroShown(): Boolean { @@ -169,6 +172,22 @@ class UserPreferences @Inject constructor( } } } + + suspend fun storePasskey(passkey: String) { + preferencesDataStore.edit { preferences -> + preferences[KEY_USER_PASSKEY] = passkey + } + } + + suspend fun getPasskey(): String? { + return preferencesDataStore.data.first()[KEY_USER_PASSKEY] + } + + suspend fun clearPasskey() { + preferencesDataStore.edit { + it.remove(KEY_USER_PASSKEY) + } + } } class BlobTypeAdapter { 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 9952a44a..b77a3d19 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 @@ -1,13 +1,19 @@ package com.canopas.yourspace.data.storage.bufferedkeystore -import com.canopas.yourspace.data.storage.database.SenderKeyDao -import com.canopas.yourspace.data.storage.database.SenderKeyEntity +import com.canopas.yourspace.data.models.user.ApiSenderKeyRecord +import com.canopas.yourspace.data.storage.UserPreferences +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS +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.runBlocking +import kotlinx.coroutines.tasks.await import org.signal.libsignal.protocol.SignalProtocolAddress import org.signal.libsignal.protocol.groups.state.SenderKeyRecord +import timber.log.Timber import java.util.UUID import javax.inject.Inject -import javax.inject.Named import javax.inject.Singleton /** @@ -15,9 +21,20 @@ import javax.inject.Singleton */ @Singleton class BufferedSenderKeyStore @Inject constructor( - @Named("sender_key_dao") private val senderKeyDao: SenderKeyDao + db: FirebaseFirestore, + private val userPreferences: UserPreferences ) : SignalServiceSenderKeyStore { + private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) + private fun spaceMemberRef(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") + .collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) + + private fun spaceSenderKeyRecordRef(spaceId: String, userId: String) = + spaceMemberRef(spaceId) + .document(userId.takeIf { it.isNotBlank() } ?: "null") + .collection(FIRESTORE_COLLECTION_USER_SENDER_KEY_RECORD) + private val store: MutableMap = HashMap() /** All of the keys that have been created or updated during operation. */ @@ -26,38 +43,54 @@ class BufferedSenderKeyStore @Inject constructor( /** All of the distributionId's whose sharing has been cleared during operation. */ private val clearSharedWith: MutableSet = mutableSetOf() - override fun storeSenderKey(sender: SignalProtocolAddress, distributionId: UUID, record: SenderKeyRecord) { + override fun storeSenderKey( + sender: SignalProtocolAddress, + distributionId: UUID, + record: SenderKeyRecord + ) { val key = StoreKey(sender, distributionId) store[key] = record updatedKeys[key] = record runBlocking { - senderKeyDao.insertSenderKey( - senderKeyEntity = SenderKeyEntity( - address = sender.name, - deviceId = sender.deviceId, - distributionId = distributionId.toString(), - record = record.serialize() - ) + val currentUser = userPreferences.currentUser ?: return@runBlocking + val senderKeyRecord = ApiSenderKeyRecord( + address = sender.name, + deviceId = sender.deviceId, + distributionId = distributionId.toString(), + record = Blob.fromBytes(record.serialize()) ) + spaceSenderKeyRecordRef( + distributionId.toString(), + currentUser.id + ).document().set(senderKeyRecord).await() } } - override fun loadSenderKey(sender: SignalProtocolAddress, distributionId: UUID): SenderKeyRecord? { + override fun loadSenderKey( + sender: SignalProtocolAddress, + distributionId: UUID + ): SenderKeyRecord? { return store[StoreKey(sender, distributionId)] ?: runBlocking { - val fromDatabase: SenderKeyRecord? = - senderKeyDao.getSenderKeyRecord( - address = sender.name, - deviceId = sender.deviceId, - distributionId = distributionId.toString() - ) - - if (fromDatabase != null) { - store[StoreKey(sender, distributionId)] = fromDatabase + val currentUser = userPreferences.currentUser ?: return@runBlocking null + val fromServer: SenderKeyRecord? = + spaceSenderKeyRecordRef( + distributionId.toString(), + currentUser.id + ).document().get().await().toObject(ApiSenderKeyRecord::class.java)?.let { + try { + SenderKeyRecord(it.record.toBytes()) + } catch (e: Exception) { + Timber.e(e, "Error while loading sender key record") + null + } + } + if (fromServer != null) { + store[StoreKey(sender, distributionId)] = fromServer } - fromDatabase + fromServer } } diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/Config.kt b/data/src/main/java/com/canopas/yourspace/data/utils/Config.kt index 7dcc5060..764475b6 100644 --- a/data/src/main/java/com/canopas/yourspace/data/utils/Config.kt +++ b/data/src/main/java/com/canopas/yourspace/data/utils/Config.kt @@ -6,6 +6,7 @@ object Config { const val FIRESTORE_COLLECTION_USERS = "users" const val FIRESTORE_COLLECTION_USER_SESSIONS = "user_sessions" + const val FIRESTORE_COLLECTION_SPACES = "spaces" const val FIRESTORE_COLLECTION_SPACE_MEMBERS = "space_members" const val FIRESTORE_COLLECTION_SPACE_PLACES = "space_places" @@ -19,5 +20,7 @@ object Config { const val FIRESTORE_COLLECTION_USER_LOCATIONS = "user_locations" const val FIRESTORE_COLLECTION_USER_JOURNEYS = "user_journeys" + const val FIRESTORE_COLLECTION_USER_SENDER_KEY_RECORD = "sender_key_record" + const val FIREBASE_REGION = "asia-south1" } 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 new file mode 100644 index 00000000..7ef40f31 --- /dev/null +++ b/data/src/main/java/com/canopas/yourspace/data/utils/PrivateKeyUtils.kt @@ -0,0 +1,115 @@ +package com.canopas.yourspace.data.utils + +import com.canopas.yourspace.data.storage.UserPreferences +import kotlinx.coroutines.runBlocking +import timber.log.Timber +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 javax.inject.Inject +import javax.inject.Singleton +import kotlin.random.Random + +class EncryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) + +@Singleton +class PrivateKeyUtils @Inject constructor(private val userPreferences: UserPreferences) { + + companion object { + private const val AES_ALGORITHM = "AES/GCM/NoPadding" + private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256" + private const val KEY_SIZE = 256 // bits + private const val ITERATION_COUNT = 10000 + private const val GCM_IV_SIZE = 12 // bytes + private const val GCM_TAG_SIZE = 128 // bits + } + + /** + * Derives a SecretKey from the user's passkey/PIN using PBKDF2. + */ + private fun deriveKeyFromPasskey(passkey: String, salt: ByteArray): SecretKey { + return try { + val spec = PBEKeySpec(passkey.toCharArray(), salt, ITERATION_COUNT, KEY_SIZE) + val factory = SecretKeyFactory.getInstance(KEY_DERIVATION_ALGORITHM) + val keyBytes = factory.generateSecret(spec).encoded + SecretKeySpec(keyBytes, "AES") + } catch (e: Exception) { + Timber.e(e, "Key derivation failed") + throw EncryptionException("Key derivation failed", e) + } + } + + /** + * Encrypts data using AES-GCM with the provided key. + * Returns the IV prepended to the ciphertext. + */ + 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 spec = GCMParameterSpec(GCM_TAG_SIZE, iv) + cipher.init(Cipher.ENCRYPT_MODE, key, spec) + val encrypted = cipher.doFinal(data) + // Prepend IV to ciphertext + iv + encrypted + } catch (e: Exception) { + Timber.e(e, "Encryption failed") + throw EncryptionException("Encryption failed", e) + } + } + + /** + * Decrypts data using AES-GCM with the provided key. + * Expects the IV to be prepended to the ciphertext. + */ + private fun decryptData(encryptedData: ByteArray, key: SecretKey): ByteArray { + return try { + if (encryptedData.size < GCM_IV_SIZE) { + throw EncryptionException("Encrypted data is too short") + } + val iv = encryptedData.copyOfRange(0, GCM_IV_SIZE) + val ciphertext = encryptedData.copyOfRange(GCM_IV_SIZE, encryptedData.size) + val cipher = Cipher.getInstance(AES_ALGORITHM) + val spec = GCMParameterSpec(GCM_TAG_SIZE, iv) + cipher.init(Cipher.DECRYPT_MODE, key, spec) + cipher.doFinal(ciphertext) + } catch (e: Exception) { + Timber.e(e, "Decryption failed") + throw EncryptionException("Decryption failed", e) + } + } + + /** + * Encrypts the private key using the user's passkey/PIN. + * Retrieves or generates the salt and stores it in Firestore via ApiUserService. + */ + fun encryptPrivateKey(privateKey: ByteArray, passkey: String, salt: ByteArray): ByteArray { + if (salt.isEmpty()) { + throw EncryptionException("Salt is empty") + } + runBlocking { + userPreferences.storePasskey(passkey) + } + val key = deriveKeyFromPasskey(passkey, salt) + return encryptData(privateKey, key) + } + + /** + * Decrypts the private key using the user's passkey/PIN and salt from ApiUser. + */ + fun decryptPrivateKey(encryptedPrivateKey: ByteArray, salt: ByteArray, pin: String?): ByteArray? { + return try { + val passkey = runBlocking { + pin ?: userPreferences.getPasskey() + } ?: return null + val key = deriveKeyFromPasskey(passkey, salt) + decryptData(encryptedPrivateKey, key) + } catch (e: EncryptionException) { + Timber.e(e, "Failed to decrypt private key") + null + } + } +} From 7099ea97d53961239f99ca621c1c74c9fc4b8ce4 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Tue, 31 Dec 2024 16:52:32 +0530 Subject: [PATCH 10/30] finish - multidevice support --- .../ui/flow/pin/enterpin/EnterPinScreen.kt | 32 +- .../ui/flow/pin/enterpin/EnterPinViewModel.kt | 8 +- .../ui/flow/pin/setpin/SetPinScreen.kt | 32 +- .../ui/flow/pin/setpin/SetPinViewModel.kt | 6 +- .../data/models/location/LocationJourney.kt | 14 +- .../data/models/location/LocationTable.kt | 21 -- .../data/service/auth/AuthService.kt | 7 +- .../service/location/ApiLocationService.kt | 321 ++++++++++-------- .../data/service/space/ApiSpaceService.kt | 16 - .../data/service/user/ApiUserService.kt | 43 ++- .../BufferedSenderKeyStore.kt | 107 +++--- .../data/storage/database/SenderKeyDao.kt | 7 - .../canopas/yourspace/data/utils/KeyHelper.kt | 48 --- .../yourspace/data/utils/PrivateKeyUtils.kt | 31 +- firestore.rules | 54 +-- 15 files changed, 369 insertions(+), 378 deletions(-) delete mode 100644 data/src/main/java/com/canopas/yourspace/data/models/location/LocationTable.kt delete mode 100644 data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt 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 666c251c..673e2a45 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 @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -18,10 +17,12 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.canopas.yourspace.ui.component.OtpInputField +import com.canopas.yourspace.ui.component.PrimaryButton @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -29,7 +30,8 @@ fun EnterPinScreen() { Scaffold( topBar = { TopAppBar(title = { Text("Enter Your PIN") }) - } + }, + contentColor = MaterialTheme.colorScheme.background ) { EnterPinContent(modifier = Modifier.padding(it)) } @@ -69,27 +71,23 @@ private fun EnterPinContent(modifier: Modifier) { Spacer(modifier = Modifier.height(16.dp)) - if (state.pinError != null) { - Text( - text = state.pinError!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp) - ) - } + Text( + text = state.pinError ?: "", + color = if (!state.pinError.isNullOrEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) Spacer(modifier = Modifier.height(24.dp)) - Button( + PrimaryButton( + label = "Continue", onClick = { viewModel.processPin() }, enabled = state.pin != "" && state.pinError == "", - modifier = Modifier - .fillMaxWidth() - .height(50.dp) - ) { - Text("Continue") - } + 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 cbe8d661..19b171c9 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 @@ -32,7 +32,9 @@ class EnterPinViewModel @Inject constructor( fun onPinChanged(newPin: String) { _state.value = _state.value.copy(pin = newPin) - validatePin(newPin) + if (newPin.length == 4) { + _state.value = _state.value.copy(pinError = "") + } } private fun validatePin(newPin: String) { @@ -58,8 +60,10 @@ class EnterPinViewModel @Inject constructor( } } - fun processPin() = viewModelScope.launch(appDispatcher.IO) { + fun processPin() = viewModelScope.launch(appDispatcher.MAIN) { + _state.value = _state.value.copy(showLoader = true) val pin = state.value.pin + validatePin(pin) if (pin.length == 4) { val isPinValid = authService.validatePasskey(passKey = pin) if (isPinValid) { 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 b27239ec..2cde40ea 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 @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -18,10 +17,12 @@ 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.canopas.yourspace.ui.component.OtpInputField +import com.canopas.yourspace.ui.component.PrimaryButton @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -29,7 +30,8 @@ fun SetPinScreen() { Scaffold( topBar = { TopAppBar(title = { Text("Set Your PIN") }) - } + }, + contentColor = MaterialTheme.colorScheme.background ) { SetPinContent(modifier = Modifier.padding(it)) } @@ -69,27 +71,23 @@ private fun SetPinContent(modifier: Modifier) { Spacer(modifier = Modifier.height(16.dp)) - if (state.pinError != null) { - Text( - text = state.pinError!!, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp) - ) - } + Text( + text = state.pinError ?: "", + color = if (!state.pinError.isNullOrEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 8.dp) + ) Spacer(modifier = Modifier.height(24.dp)) - Button( + PrimaryButton( + label = "Set Pin", onClick = { viewModel.processPin() }, enabled = state.pin != "" && state.pinError == "", - modifier = Modifier - .fillMaxWidth() - .height(50.dp) - ) { - Text("Set Pin") - } + 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 55bac9b9..7afbfb74 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 @@ -15,7 +15,6 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch -import timber.log.Timber import javax.inject.Inject @HiltViewModel @@ -72,11 +71,8 @@ class SetPinViewModel @Inject constructor( authService.generateAndSaveUserKeys(passKey = pin) val userId = authService.getUser()?.id val userHasSpaces = userId?.let { - val flowList = spaceRepository.getUserSpaces(it) - Timber.e("XXXXXX: Flowlist - ${flowList.firstOrNull()}") - flowList.firstOrNull()?.isNotEmpty() ?: false + spaceRepository.getUserSpaces(it).firstOrNull()?.isNotEmpty() ?: false } - Timber.e("XXXXXX: User has spaces - $userHasSpaces") if (userHasSpaces == false) { navigator.navigateTo( AppDestinations.onboard.path, 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 d2b9e617..a8d5c53c 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 @@ -27,13 +27,13 @@ data class LocationJourney( data class EncryptedLocationJourney( val id: String = UUID.randomUUID().toString(), val user_id: String = "", - val encrypted_from_latitude: String = "", // Base64 encoded - val encrypted_from_longitude: String = "", - val encrypted_to_latitude: String? = "", - val encrypted_to_longitude: String? = "", + val encrypted_from_latitude: String = "", // Base64 encoded encrypted latitude - from + val encrypted_from_longitude: String = "", // Base64 encoded encrypted longitude - from + val encrypted_to_latitude: String? = "", // Base64 encoded encrypted latitude - to + val encrypted_to_longitude: String? = "", // Base64 encoded encrypted longitude - to val route_distance: Double? = null, val route_duration: Long? = null, - val encrypted_routes: List = emptyList(), + val encrypted_routes: List = emptyList(), // Base64 encoded encrypted journey routes val created_at: Long? = System.currentTimeMillis(), val updated_at: Long? = System.currentTimeMillis() ) @@ -44,8 +44,8 @@ data class JourneyRoute(val latitude: Double = 0.0, val longitude: Double = 0.0) @Keep @JsonClass(generateAdapter = true) data class EncryptedJourneyRoute( - val encrypted_latitude: String = "", - val encrypted_longitude: String = "" + val encrypted_latitude: String = "", // Base64 encoded encrypted latitude + val encrypted_longitude: String = "" // Base64 encoded encrypted longitude ) fun Location.toRoute(): JourneyRoute { diff --git a/data/src/main/java/com/canopas/yourspace/data/models/location/LocationTable.kt b/data/src/main/java/com/canopas/yourspace/data/models/location/LocationTable.kt deleted file mode 100644 index e7e5fbe3..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/models/location/LocationTable.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.canopas.yourspace.data.models.location - -import androidx.room.ColumnInfo -import androidx.room.Entity -import androidx.room.PrimaryKey -import com.squareup.moshi.JsonClass - -@JsonClass(generateAdapter = true) -@Entity(tableName = "location_table") -data class LocationTable( - - @PrimaryKey - @ColumnInfo(name = "user_id") - val userId: String = "", - - @ColumnInfo(name = "last_five_locations") - val lastFiveLocations: String? = null, // last 5 extracted locations - - @ColumnInfo(name = "last_location_journey") - val lastLocationJourney: String? = null // last journey -) 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 8ad4d760..56882e3b 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 @@ -9,6 +9,7 @@ import com.canopas.yourspace.data.storage.UserPreferences import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser +import com.google.firebase.firestore.Blob import javax.inject.Inject import javax.inject.Singleton @@ -120,7 +121,10 @@ class AuthService @Inject constructor( } suspend fun generateAndSaveUserKeys(passKey: String) { - currentUser?.id?.let { apiUserService.generateAndSaveUserKeys(it, passKey) } + currentUser?.let { + val updatedUser = apiUserService.generateAndSaveUserKeys(currentUser!!, passKey) + currentUser = updatedUser + } } suspend fun validatePasskey(passKey: String): Boolean { @@ -128,6 +132,7 @@ class AuthService @Inject constructor( val validationResult = apiUserService.validatePasskey(user, passKey) if (validationResult != null) { userPreferences.storePasskey(passKey) + currentUser = currentUser?.copy(identity_key_private = Blob.fromBytes(validationResult)) } return validationResult != 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 6d7faa3a..e8d1a424 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 @@ -4,12 +4,14 @@ 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.SenderKeyDistribution +import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.canopas.yourspace.data.utils.EphemeralECDHUtils +import com.canopas.yourspace.data.utils.PrivateKeyUtils import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.Blob import com.google.firebase.firestore.FieldValue @@ -18,8 +20,10 @@ import com.google.firebase.firestore.Query import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.tasks.await +import org.signal.libsignal.protocol.InvalidKeyException import org.signal.libsignal.protocol.SignalProtocolAddress import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.protocol.ecc.ECPrivateKey import org.signal.libsignal.protocol.groups.GroupCipher import org.signal.libsignal.protocol.groups.GroupSessionBuilder import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage @@ -37,51 +41,31 @@ class ApiLocationService @Inject constructor( ) { var currentSpaceId: String = userPreferences.currentSpace ?: "" - private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) + private val spaceRef by lazy { db.collection(FIRESTORE_COLLECTION_SPACES) } + private fun spaceMemberRef(spaceId: String) = - spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( - FIRESTORE_COLLECTION_SPACE_MEMBERS - ) + spaceRef.document(spaceId).collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) private fun spaceMemberLocationRef(spaceId: String, userId: String) = - spaceMemberRef(spaceId) - .document(userId.takeIf { it.isNotBlank() } ?: "null") - .collection(Config.FIRESTORE_COLLECTION_USER_LOCATIONS) + spaceMemberRef(spaceId).document(userId).collection(Config.FIRESTORE_COLLECTION_USER_LOCATIONS) private fun spaceGroupKeysRef(spaceId: String) = - spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( - Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS - ) + spaceRef.document(spaceId).collection(Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) - suspend fun saveLastKnownLocation( - userId: String - ) { + suspend fun saveLastKnownLocation(userId: String) { val lastLocation = locationManager.getLastLocation() ?: return - userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val cipherAndDistributionMessage = - getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = true) - val groupCipher = cipherAndDistributionMessage?.second ?: return - val distributionMessage = cipherAndDistributionMessage.first - val lat = groupCipher.encrypt( - distributionMessage.distributionId, - lastLocation.latitude.toString().toByteArray(Charsets.UTF_8) - ) - val lon = groupCipher.encrypt( - distributionMessage.distributionId, - lastLocation.longitude.toString().toByteArray(Charsets.UTF_8) - ) + val currentUser = userPreferences.currentUser ?: return - val docRef = spaceMemberLocationRef(spaceId, userId).document() + currentUser.space_ids?.forEach { spaceId -> + if (spaceId.isBlank()) return@forEach - val location = EncryptedApiLocation( - id = docRef.id, - user_id = userId, - encrypted_latitude = Blob.fromBytes(lat.serialize()), - encrypted_longitude = Blob.fromBytes(lon.serialize()), - created_at = System.currentTimeMillis() + saveEncryptedLocation( + spaceId = spaceId, + userId = userId, + latitude = lastLocation.latitude, + longitude = lastLocation.longitude, + recordedAt = System.currentTimeMillis() ) - - docRef.set(location).await() } } @@ -91,123 +75,179 @@ class ApiLocationService @Inject constructor( longitude: Double, recordedAt: Long ) { - userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val cipherAndDistributionMessage = - getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = true) - val groupCipher = cipherAndDistributionMessage?.second ?: return - val distributionMessage = cipherAndDistributionMessage.first - val lat = groupCipher.encrypt( - distributionMessage.distributionId, - latitude.toString().toByteArray(Charsets.UTF_8) - ) - val lon = groupCipher.encrypt( - distributionMessage.distributionId, - longitude.toString().toByteArray(Charsets.UTF_8) - ) + val currentUser = userPreferences.currentUser ?: return - Timber.e("YYYYYY: LAt: $lat, Lon: $lon") - val docRef = spaceMemberLocationRef(spaceId, userId).document() + currentUser.space_ids?.forEach { spaceId -> + if (spaceId.isBlank()) return@forEach - val location = EncryptedApiLocation( - id = docRef.id, - user_id = userId, - encrypted_latitude = Blob.fromBytes(lat.serialize()), - encrypted_longitude = Blob.fromBytes(lon.serialize()), - created_at = recordedAt + saveEncryptedLocation( + spaceId = spaceId, + userId = userId, + latitude = latitude, + longitude = longitude, + recordedAt = recordedAt ) + } + } + + private suspend fun saveEncryptedLocation( + spaceId: String, + userId: String, + latitude: Double, + longitude: Double, + recordedAt: Long + ) { + val cipherAndDistribution = getGroupCipherAndDistributionMessage( + spaceId = spaceId, + userId = userId, + canDistributeSenderKey = true + ) ?: return + + val (distributionMessage, groupCipher) = cipherAndDistribution + + val encryptedLatitude = groupCipher.encrypt( + distributionMessage.distributionId, + latitude.toString().toByteArray(Charsets.UTF_8) + ) + val encryptedLongitude = groupCipher.encrypt( + distributionMessage.distributionId, + longitude.toString().toByteArray(Charsets.UTF_8) + ) + + val location = EncryptedApiLocation( + id = UUID.randomUUID().toString(), + user_id = userId, + encrypted_latitude = Blob.fromBytes(encryptedLatitude.serialize()), + encrypted_longitude = Blob.fromBytes(encryptedLongitude.serialize()), + created_at = recordedAt + ) - docRef.set(location).await() + try { + spaceMemberLocationRef(spaceId, userId).document(location.id).set(location).await() + } catch (e: Exception) { + Timber.e(e, "Failed to save encrypted location for userId: $userId in spaceId: $spaceId") } } - suspend fun getCurrentLocation(userId: String): Flow> { - return flow { - try { - Timber.e("YYYYYY: Here") - val encryptedLocation = - spaceMemberLocationRef(currentSpaceId, userId).whereEqualTo("user_id", userId) - .orderBy("created_at", Query.Direction.DESCENDING).limit(1) - .snapshotFlow(EncryptedApiLocation::class.java) - Timber.e("YYYYYY: Here - encryptedLocation: $encryptedLocation") - encryptedLocation.collect { encryptedLocationList -> - val apiLocations = encryptedLocationList.map { encryptedLocation -> - val receiverGroupCipher = - getGroupCipherAndDistributionMessage(currentSpaceId, userId)?.second - ?: return@map null - val lat = - receiverGroupCipher.decrypt(encryptedLocation.encrypted_latitude.toBytes()) - val lon = - receiverGroupCipher.decrypt(encryptedLocation.encrypted_longitude.toBytes()) - - ApiLocation( - id = encryptedLocation.id, - user_id = userId, - latitude = lat.toString(Charsets.UTF_8).toDouble(), - longitude = lon.toString(Charsets.UTF_8).toDouble(), - created_at = encryptedLocation.created_at - ) + fun getCurrentLocation(userId: String): Flow> = flow { + try { + val encryptedLocationsFlow = spaceMemberLocationRef(currentSpaceId, userId) + .whereEqualTo("user_id", userId) + .orderBy("created_at", Query.Direction.DESCENDING) + .limit(1) + .snapshotFlow(EncryptedApiLocation::class.java) + + encryptedLocationsFlow.collect { encryptedLocationList -> + emit( + encryptedLocationList.mapNotNull { encryptedLocation -> + decryptLocation(encryptedLocation, userId) } - Timber.e("YYYYYY: Here - apiLocations: $apiLocations") - emit(apiLocations) - } - } catch (e: Exception) { - Timber.e(e, "Error while getting current location") + ) } + } catch (e: Exception) { + Timber.e(e, "Error while getting current location for userId: $userId") } } + private suspend fun decryptLocation(encryptedLocation: EncryptedApiLocation, userId: String): ApiLocation? { + val groupCipher = getGroupCipherAndDistributionMessage(currentSpaceId, userId)?.second ?: return null + + return try { + val latitudeBytes = groupCipher.decrypt(encryptedLocation.encrypted_latitude.toBytes()) + val longitudeBytes = groupCipher.decrypt(encryptedLocation.encrypted_longitude.toBytes()) + + ApiLocation( + id = encryptedLocation.id, + user_id = userId, + latitude = latitudeBytes.toString(Charsets.UTF_8).toDouble(), + longitude = longitudeBytes.toString(Charsets.UTF_8).toDouble(), + created_at = encryptedLocation.created_at + ) + } catch (e: Exception) { + Timber.e(e, "Failed to decrypt location for userId: $userId") + null + } + } + + /** + * Provide group cipher and sender key distribution message for a particular space and user. + */ private suspend fun getGroupCipherAndDistributionMessage( spaceId: String, userId: String, canDistributeSenderKey: Boolean = false - ): Pair? { + ): Pair? { val currentUser = userPreferences.currentUser ?: return null + val senderKeyDistribution = spaceGroupKeysRef(spaceId) - .document(userId).get().await().toObject(SenderKeyDistribution::class.java) - - val distributions = senderKeyDistribution?.distributions ?: return null - val currentUserDistribution = distributions.firstOrNull { it.recipientId == currentUser.id } - if (currentUserDistribution == null && canDistributeSenderKey) { - Timber.e( - "Sender key distribution not found for $userId in space $spaceId.\n+" + - "Can be a new member. Distributing sender key to new member..." - ) - distributeSenderKeyToNewMember( - spaceId = spaceId, - senderUserId = senderKeyDistribution.senderId, - newMember = ApiSpaceMember( - user_id = userId, - space_id = spaceId, - identity_key_public = currentUser.identity_key_public - ), - senderDeviceId = senderKeyDistribution.senderDeviceId - ) - getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = false) - } - if (currentUserDistribution == null) { - return null - } - val currentUserPrivateKey = - Curve.decodePrivatePoint(currentUser.identity_key_private?.toBytes()) + .document(userId) + .get() + .await() + .toObject(SenderKeyDistribution::class.java) ?: return null + + val distribution = senderKeyDistribution.distributions + .firstOrNull { it.recipientId == currentUser.id } + ?: run { + if (canDistributeSenderKey) { + Timber.d("Distributing sender key to new member: userId=$userId in spaceId=$spaceId") + distributeSenderKeyToNewMember( + spaceId = spaceId, + senderUserId = senderKeyDistribution.senderId, + newMember = ApiSpaceMember( + user_id = userId, + space_id = spaceId, + identity_key_public = currentUser.identity_key_public + ), + senderDeviceId = senderKeyDistribution.senderDeviceId + ) + // Retry fetching the distribution after distribution + return getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = false) + } + null + } ?: return null - val decryptedDistribution = - EphemeralECDHUtils.decrypt(currentUserDistribution, currentUserPrivateKey) + val currentUserPrivateKey = getCurrentUserPrivateKey(currentUser) ?: return null + + val decryptedDistribution = EphemeralECDHUtils.decrypt(distribution, currentUserPrivateKey) + ?: run { + Timber.e("Failed to decrypt distribution for userId=$userId in spaceId=$spaceId") + return null + } val distributionMessage = SenderKeyDistributionMessage(decryptedDistribution) - val senderAddress = SignalProtocolAddress( - spaceId, - senderKeyDistribution.senderDeviceId - ) + val senderAddress = SignalProtocolAddress(spaceId, senderKeyDistribution.senderDeviceId) - // Load sender key for particular distributionId/space bufferedSenderKeyStore.loadSenderKey(senderAddress, distributionMessage.distributionId) - val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) - val receiverGroupCipher = GroupCipher(bufferedSenderKeyStore, senderAddress) - sessionBuilder.process(senderAddress, distributionMessage) - return Pair(distributionMessage, receiverGroupCipher) + return try { + GroupSessionBuilder(bufferedSenderKeyStore).process(senderAddress, distributionMessage) + val groupCipher = GroupCipher(bufferedSenderKeyStore, senderAddress) + Pair(distributionMessage, groupCipher) + } catch (e: Exception) { + Timber.e(e, "Error processing group session for spaceId=$spaceId, userId=$userId") + null + } } + /** + * Decrypts and retrieves the current user's private key. + */ + private suspend fun getCurrentUserPrivateKey(currentUser: ApiUser): ECPrivateKey? { + return try { + Curve.decodePrivatePoint(currentUser.identity_key_private?.toBytes()) + } catch (e: InvalidKeyException) { + Timber.e(e, "Error decoding private key for userId=${currentUser.id}") + PrivateKeyUtils.decryptPrivateKey( + currentUser.identity_key_private?.toBytes() ?: return null, + currentUser.identity_key_salt?.toBytes() ?: return null, + userPreferences.getPasskey() ?: return null + )?.let { Curve.decodePrivatePoint(it) } + } + } + + /** + * Distribute sender key to a new member. + */ private suspend fun distributeSenderKeyToNewMember( spaceId: String, senderUserId: String, @@ -218,19 +258,32 @@ class ApiLocationService @Inject constructor( val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) val distributionMessage = sessionBuilder.create(groupAddress, UUID.fromString(spaceId)) val distributionBytes = distributionMessage.serialize() - val publicBlob = newMember.identity_key_public ?: return - val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) - val docRef = spaceGroupKeysRef(spaceId).document(senderUserId) + + val publicBlob = newMember.identity_key_public ?: run { + Timber.e("New member's public key is null for userId=${newMember.user_id}") + return + } + + val publicKey = try { + Curve.decodePoint(publicBlob.toBytes(), 0) + } catch (e: InvalidKeyException) { + Timber.e(e, "Invalid public key for new member userId=${newMember.user_id}") + return + } val distribution = EphemeralECDHUtils.encrypt( newMember.user_id, distributionBytes, publicKey ) - docRef.update( - "distributions", - FieldValue.arrayUnion(distribution) - ).await() - Timber.d("Sender key distribution uploaded for new member: ${newMember.user_id} for sender $senderUserId.") + + try { + spaceGroupKeysRef(spaceId).document(senderUserId) + .update("distributions", FieldValue.arrayUnion(distribution)) + .await() + Timber.d("Sender key distribution uploaded for new member: ${newMember.user_id} in spaceId=$spaceId.") + } catch (e: Exception) { + Timber.e(e, "Failed to upload sender key distribution for new member userId=${newMember.user_id} in spaceId=$spaceId") + } } } 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 7627c045..d478b1f5 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 @@ -66,7 +66,6 @@ class ApiSpaceService @Inject constructor( suspend fun joinSpace(spaceId: String, role: Int = SPACE_MEMBER_ROLE_MEMBER) { val user = authService.currentUser ?: return - Timber.e("SpaceId: $spaceId, UserId: ${user.id}") spaceMemberRef(spaceId) .document(user.id).also { val member = ApiSpaceMember( @@ -145,21 +144,6 @@ class ApiSpaceService @Inject constructor( db.collectionGroup(FIRESTORE_COLLECTION_SPACE_MEMBERS).whereEqualTo("user_id", userId) .snapshotFlow(ApiSpaceMember::class.java) - suspend fun doesUserHaveAnySpace(userId: String): Boolean { - return try { - val querySnapshot = db.collectionGroup(FIRESTORE_COLLECTION_SPACE_MEMBERS) - .whereEqualTo("user_id", userId) - .limit(1) - .get() - .await() - - querySnapshot.documents.isNotEmpty() - } catch (e: Exception) { - Timber.e(e, "Error checking if user is part of any space") - false - } - } - fun getMemberBySpaceId(spaceId: String) = spaceMemberRef(spaceId).snapshotFlow(ApiSpaceMember::class.java) 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 34ccf0eb..97cddcbe 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 @@ -10,7 +10,6 @@ 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.KeyHelper import com.canopas.yourspace.data.utils.PrivateKeyUtils import com.canopas.yourspace.data.utils.snapshotFlow import com.google.android.gms.auth.api.signin.GoogleSignInAccount @@ -24,6 +23,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext +import org.signal.libsignal.protocol.IdentityKey +import org.signal.libsignal.protocol.IdentityKeyPair +import org.signal.libsignal.protocol.ecc.Curve import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -37,8 +39,7 @@ class ApiUserService @Inject constructor( private val device: Device, private val locationService: ApiLocationService, private val functions: FirebaseFunctions, - userPreferences: UserPreferences, - private val privateKeyUtils: PrivateKeyUtils + private val userPreferences: UserPreferences ) { private val currentUser = userPreferences.currentUser private val userRef = db.collection(FIRESTORE_COLLECTION_USERS) @@ -118,27 +119,41 @@ class ApiUserService @Inject constructor( } } - suspend fun saveSenderKeyRecord(address: String, deviceId: Int, distributionId: String) { - } - - suspend fun generateAndSaveUserKeys(userId: String, passKey: String) { - val identityKeyPair = KeyHelper.generateIdentityKeyPair() + suspend fun generateAndSaveUserKeys(user: ApiUser, passKey: String): ApiUser { + val identityKeyPair = generateIdentityKeyPair() val salt = ByteArray(16).apply { Random.nextBytes(this) } - val encryptedPrivateKey = privateKeyUtils.encryptPrivateKey( + val encryptedPrivateKey = PrivateKeyUtils.encryptPrivateKey( identityKeyPair.privateKey.serialize(), passkey = passKey, salt = salt ) - userRef.document(userId).update( + // Store passkey in preferences + userPreferences.storePasskey(passKey) + + userRef.document(user.id).update( mapOf( "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( + identity_key_public = Blob.fromBytes(identityKeyPair.publicKey.publicKey.serialize()), + identity_key_private = Blob.fromBytes(identityKeyPair.privateKey.serialize()), + identity_key_salt = Blob.fromBytes(salt) + ) + } + + /** + * Generates a new IdentityKeyPair. + * */ + private fun generateIdentityKeyPair(): IdentityKeyPair { + val keyPair = Curve.generateKeyPair() + val publicKey = IdentityKey(keyPair.publicKey) + return IdentityKeyPair(publicKey, keyPair.privateKey) } - fun validatePasskey(user: ApiUser, passKey: String): ByteArray? { + suspend fun validatePasskey(user: ApiUser, passKey: String): ByteArray? { val decryptedPrivateKey = decryptPrivateKey(user, passKey) return if (decryptedPrivateKey != null) { decryptedPrivateKey @@ -152,12 +167,12 @@ class ApiUserService @Inject constructor( * Decrypts the private key using the stored passkey/PIN and salt from ApiUser. * Returns the decrypted private key as ByteArray. */ - private fun decryptPrivateKey(user: ApiUser, pin: String? = null): ByteArray? { + private suspend fun decryptPrivateKey(user: ApiUser, pin: String? = null): ByteArray? { val encryptedPrivateKey = user.identity_key_private?.toBytes() ?: return null val salt = user.identity_key_salt?.toBytes() ?: return null return try { - val decrypted = privateKeyUtils.decryptPrivateKey(encryptedPrivateKey, salt, pin) - Timber.e("XXXXXX: Encrypted Private Key: $encryptedPrivateKey\nDecrypted: $decrypted") + val passkey = pin ?: userPreferences.getPasskey() ?: return null + val decrypted = PrivateKeyUtils.decryptPrivateKey(encryptedPrivateKey, salt, passkey) decrypted } catch (e: EncryptionException) { Timber.e(e, "Failed to decrypt private key for user ${user.id}") 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 b77a3d19..fdaa5431 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 @@ -2,6 +2,8 @@ package com.canopas.yourspace.data.storage.bufferedkeystore import com.canopas.yourspace.data.models.user.ApiSenderKeyRecord import com.canopas.yourspace.data.storage.UserPreferences +import com.canopas.yourspace.data.storage.database.SenderKeyDao +import com.canopas.yourspace.data.storage.database.SenderKeyEntity import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_USER_SENDER_KEY_RECORD @@ -14,34 +16,38 @@ import org.signal.libsignal.protocol.groups.state.SenderKeyRecord import timber.log.Timber import java.util.UUID import javax.inject.Inject +import javax.inject.Named import javax.inject.Singleton /** - * An in-memory sender key store that is intended to be used temporarily while decrypting messages. + * An in-memory sender key store with persistent and server backup. */ @Singleton class BufferedSenderKeyStore @Inject constructor( db: FirebaseFirestore, + @Named("sender_key_dao") private val senderKeyDao: SenderKeyDao, private val userPreferences: UserPreferences ) : SignalServiceSenderKeyStore { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) + private fun spaceMemberRef(spaceId: String) = - spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") + spaceRef.document(spaceId.ifBlank { "null" }) .collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) private fun spaceSenderKeyRecordRef(spaceId: String, userId: String) = spaceMemberRef(spaceId) - .document(userId.takeIf { it.isNotBlank() } ?: "null") + .document(userId.ifBlank { "null" }) .collection(FIRESTORE_COLLECTION_USER_SENDER_KEY_RECORD) - private val store: MutableMap = HashMap() - - /** All of the keys that have been created or updated during operation. */ - private val updatedKeys: MutableMap = mutableMapOf() + private val inMemoryStore: MutableMap = mutableMapOf() + private val sharedWithAddresses: MutableSet = mutableSetOf() - /** All of the distributionId's whose sharing has been cleared during operation. */ - private val clearSharedWith: MutableSet = mutableSetOf() + private suspend fun saveSenderKeyToServer(distributionId: UUID, senderKeyRecord: ApiSenderKeyRecord) { + val currentUser = userPreferences.currentUser ?: return + spaceSenderKeyRecordRef(distributionId.toString(), currentUser.id) + .document(distributionId.toString()).set(senderKeyRecord).await() + } override fun storeSenderKey( sender: SignalProtocolAddress, @@ -49,72 +55,77 @@ class BufferedSenderKeyStore @Inject constructor( record: SenderKeyRecord ) { val key = StoreKey(sender, distributionId) - store[key] = record - updatedKeys[key] = record + if (inMemoryStore.containsKey(key)) { + Timber.d("Sender key already exists for $sender and $distributionId") + return + } + inMemoryStore[key] = record runBlocking { - val currentUser = userPreferences.currentUser ?: return@runBlocking + senderKeyDao.insertSenderKey( + SenderKeyEntity( + address = sender.name, + deviceId = sender.deviceId, + distributionId = distributionId.toString(), + record = record.serialize() + ) + ) + val senderKeyRecord = ApiSenderKeyRecord( address = sender.name, deviceId = sender.deviceId, distributionId = distributionId.toString(), record = Blob.fromBytes(record.serialize()) ) - spaceSenderKeyRecordRef( - distributionId.toString(), - currentUser.id - ).document().set(senderKeyRecord).await() + saveSenderKeyToServer(distributionId, senderKeyRecord) } } - override fun loadSenderKey( - sender: SignalProtocolAddress, - distributionId: UUID - ): SenderKeyRecord? { - return store[StoreKey(sender, distributionId)] - ?: runBlocking { - val currentUser = userPreferences.currentUser ?: return@runBlocking null - val fromServer: SenderKeyRecord? = - spaceSenderKeyRecordRef( - distributionId.toString(), - currentUser.id - ).document().get().await().toObject(ApiSenderKeyRecord::class.java)?.let { - try { - SenderKeyRecord(it.record.toBytes()) - } catch (e: Exception) { - Timber.e(e, "Error while loading sender key record") - null - } - } - if (fromServer != null) { - store[StoreKey(sender, distributionId)] = fromServer - } + override fun loadSenderKey(sender: SignalProtocolAddress, distributionId: UUID): SenderKeyRecord? { + val key = StoreKey(sender, distributionId) + return inMemoryStore[key] ?: runBlocking { + senderKeyDao.getSenderKeyRecord( + address = sender.name, + deviceId = sender.deviceId, + distributionId = distributionId.toString() + )?.let { + inMemoryStore[key] = it + it + } ?: fetchSenderKeyFromServer(distributionId)?.also { + inMemoryStore[key] = it + } + } + } - fromServer + private suspend fun fetchSenderKeyFromServer(distributionId: UUID): SenderKeyRecord? { + val currentUser = userPreferences.currentUser ?: return null + return spaceSenderKeyRecordRef(distributionId.toString(), currentUser.id) + .document(distributionId.toString()).get().await().toObject(ApiSenderKeyRecord::class.java)?.let { + try { + SenderKeyRecord(it.record.toBytes()) + } catch (e: Exception) { + Timber.e(e, "Failed to deserialize sender key record") + null + } } } override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet { - error("Should not happen during the intended usage pattern of this class") + throw UnsupportedOperationException("Should not happen during the intended usage pattern of this class") } override fun markSenderKeySharedWith( distributionId: DistributionId?, addresses: Collection? ) { - error("Should not happen during the intended usage pattern of this class") + throw UnsupportedOperationException("Should not happen during the intended usage pattern of this class") } override fun clearSenderKeySharedWith(addresses: Collection?) { addresses?.forEach { address -> - address?.let { clearSharedWith.add(it) } + address?.let { sharedWithAddresses.add(it) } } } - private fun UUID.toDistributionId() = DistributionId.from(this) - - data class StoreKey( - val address: SignalProtocolAddress, - val distributionId: UUID - ) + data class StoreKey(val address: SignalProtocolAddress, val distributionId: UUID) } diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt index 3fa856a2..78531780 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt @@ -55,11 +55,4 @@ interface SenderKeyDao { deviceId: Int, distributionId: String ) - - @Query( - """ - SELECT * FROM sender_keys - """ - ) - suspend fun getAllSenderKeys(): List } diff --git a/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt b/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt deleted file mode 100644 index f5ef5fbf..00000000 --- a/data/src/main/java/com/canopas/yourspace/data/utils/KeyHelper.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.canopas.yourspace.data.utils - -import org.signal.libsignal.protocol.IdentityKey -import org.signal.libsignal.protocol.IdentityKeyPair -import org.signal.libsignal.protocol.ecc.Curve -import org.signal.libsignal.protocol.state.PreKeyRecord -import org.signal.libsignal.protocol.state.SignedPreKeyRecord -import org.signal.libsignal.protocol.util.KeyHelper -import org.signal.libsignal.zkgroup.profiles.ProfileKey -import java.security.SecureRandom - -private const val INTEGER_MAX = 0x7fffffff - -object KeyHelper { - fun generateIdentityKeyPair(): IdentityKeyPair { - val keyPair = Curve.generateKeyPair() - val publicKey = IdentityKey(keyPair.publicKey) - return IdentityKeyPair(publicKey, keyPair.privateKey) - } - - fun getProfileKey(): ProfileKey { - return ProfileKey(SecureRandom().generateSeed(32)) - } - - fun generateRegistrationId(extendedRange: Boolean): Int { - return KeyHelper.generateRegistrationId(extendedRange) - } - - fun generatePreKeys(start: Int, count: Int): List { - val results = mutableListOf() - for (i in 0 until count) { - results.add(PreKeyRecord(start + i, Curve.generateKeyPair())) - } - return results - } - - fun generateSignedPreKey(identityKeyPair: IdentityKeyPair, signedPreKeyId: Int): SignedPreKeyRecord { - val keyPair = Curve.generateKeyPair() - val signature = - Curve.calculateSignature(identityKeyPair.privateKey, keyPair.publicKey.serialize()) - return SignedPreKeyRecord(signedPreKeyId, System.currentTimeMillis(), keyPair, signature) - } - - fun generateSignedPreKeyId(): Int { - val random = SecureRandom() - return random.nextInt(INTEGER_MAX) - } -} 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 7ef40f31..abd45f43 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,7 +1,5 @@ package com.canopas.yourspace.data.utils -import com.canopas.yourspace.data.storage.UserPreferences -import kotlinx.coroutines.runBlocking import timber.log.Timber import javax.crypto.Cipher import javax.crypto.SecretKey @@ -9,23 +7,18 @@ import javax.crypto.SecretKeyFactory import javax.crypto.spec.GCMParameterSpec import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.SecretKeySpec -import javax.inject.Inject -import javax.inject.Singleton import kotlin.random.Random -class EncryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) +private const val AES_ALGORITHM = "AES/GCM/NoPadding" +private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256" +private const val KEY_SIZE = 256 // bits +private const val ITERATION_COUNT = 10000 +private const val GCM_IV_SIZE = 12 // bytes +private const val GCM_TAG_SIZE = 128 // bits -@Singleton -class PrivateKeyUtils @Inject constructor(private val userPreferences: UserPreferences) { +class EncryptionException(message: String, cause: Throwable? = null) : Exception(message, cause) - companion object { - private const val AES_ALGORITHM = "AES/GCM/NoPadding" - private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256" - private const val KEY_SIZE = 256 // bits - private const val ITERATION_COUNT = 10000 - private const val GCM_IV_SIZE = 12 // bytes - private const val GCM_TAG_SIZE = 128 // bits - } +object PrivateKeyUtils { /** * Derives a SecretKey from the user's passkey/PIN using PBKDF2. @@ -90,9 +83,6 @@ class PrivateKeyUtils @Inject constructor(private val userPreferences: UserPrefe if (salt.isEmpty()) { throw EncryptionException("Salt is empty") } - runBlocking { - userPreferences.storePasskey(passkey) - } val key = deriveKeyFromPasskey(passkey, salt) return encryptData(privateKey, key) } @@ -100,11 +90,8 @@ class PrivateKeyUtils @Inject constructor(private val userPreferences: UserPrefe /** * Decrypts the private key using the user's passkey/PIN and salt from ApiUser. */ - fun decryptPrivateKey(encryptedPrivateKey: ByteArray, salt: ByteArray, pin: String?): ByteArray? { + fun decryptPrivateKey(encryptedPrivateKey: ByteArray, salt: ByteArray, passkey: String): ByteArray? { return try { - val passkey = runBlocking { - pin ?: userPreferences.getPasskey() - } ?: return null val key = deriveKeyFromPasskey(passkey, salt) decryptData(encryptedPrivateKey, key) } catch (e: EncryptionException) { diff --git a/firestore.rules b/firestore.rules index bb5fdc98..e42b1d39 100644 --- a/firestore.rules +++ b/firestore.rules @@ -131,11 +131,44 @@ service cloud.firestore { allow write: if false; } + match /spaces/{spaceId}/group_keys/{senderId} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + + allow create: if isAuthorized() && + request.auth.uid == senderId && + isSpaceMember(spaceId) && + request.resource.data.keys().hasAll(["senderId", "distributions", "createdAt"]) && + request.resource.data.senderId is string && + request.resource.data.distributions is list && + request.resource.data.createdAt is int; + + allow update: if isAuthorized() && isSpaceMember(spaceId); + allow delete: if isAuthorized() && isSpaceMember(spaceId); + } + + match /spaces/{spaceId}/space_members/{userId}/sender_key_record/{docId} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + allow write: if isAuthorized() && request.auth.uid == userId && isSpaceMember(spaceId); + allow delete: if false; // Disallow deletion for safety, adjust if needed. + allow create: if isAuthorized() && + request.auth.uid == userId && + isSpaceMember(spaceId) && + request.resource.data.keys().hasAll(["address", "deviceId", "distributionId", "record"]) && + request.resource.data.address is string && + request.resource.data.deviceId is int && + request.resource.data.distributionId is string && + request.resource.data.record is bytes; + } + match /spaces/{spaceId}/space_places/{place} { allow read: if isAuthorized() && isSpaceMember(spaceId); allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.created_by); - allow update: if isAuthorized() && request.auth.uid == resource.data.created_by; + allow update: if isAuthorized() && + (request.auth.uid == resource.data.created_by || + (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["space_member_ids"]) && + request.resource.data.space_member_ids.hasAny([request.auth.uid]) + )); allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.created_by) && request.resource.data.keys().hasAll(["id", "space_id", "created_by", "latitude", "longitude", "radius", "name", "created_at"]) && @@ -180,21 +213,6 @@ service cloud.firestore { allow write: if false; } - match /spaces/{spaceId}/group_keys/{senderId} { - allow read: if isAuthorized() && isSpaceMember(spaceId); - - allow create: if isAuthorized() && - request.auth.uid == senderId && - isSpaceMember(spaceId) && - request.resource.data.keys().hasAll(["senderId", "distributions", "createdAt"]) && - request.resource.data.senderId is string && - request.resource.data.distributions is list && - request.resource.data.createdAt is int; - - allow update: if false; - allow delete: if false; - } - match /spaces/{spaceId}/space_members/{member} { allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); @@ -267,9 +285,7 @@ service cloud.firestore { allow delete: if isAuthorized() && isThreadAdmin(docId); allow update: if isAuthorized() && isThreadMember(docId) && - request.resource.data.diff(resource.data).affectedKeys().hasAny(["member_ids", "archived_for"]) && - request.resource.data.get('member_ids', []) is list && - request.resource.data.get('archived_for', {}) is map; + request.resource.data.diff(resource.data).affectedKeys().hasAny(["member_ids", "archived_for", "seen_by_ids"]); allow create: if isAuthorized() && (isSpaceMember(request.resource.data.space_id)) && request.resource.data.keys().hasAll(["id", "space_id", "admin_id", "member_ids", "created_at"]) && From 2aed3d25655838b6075d69dac1c489f9707d8fcc Mon Sep 17 00:00:00 2001 From: cp-megh Date: Wed, 1 Jan 2025 13:46:10 +0530 Subject: [PATCH 11/30] final commit --- .../canopas/yourspace/YourSpaceApplication.kt | 2 +- .../ui/flow/pin/enterpin/EnterPinScreen.kt | 35 +- .../ui/flow/pin/enterpin/EnterPinViewModel.kt | 23 +- .../ui/flow/pin/setpin/SetPinScreen.kt | 35 +- .../ui/flow/pin/setpin/SetPinViewModel.kt | 18 +- app/src/main/res/values/strings.xml | 13 + .../auth/methods/SignInMethodViewModelTest.kt | 19 +- .../data/models/location/LocationJourney.kt | 15 +- .../service/location/ApiJourneyService.kt | 329 ++++++++++++++---- .../service/location/ApiLocationService.kt | 36 +- .../BufferedSenderKeyStore.kt | 16 +- firestore.rules | 65 ++-- 12 files changed, 430 insertions(+), 176 deletions(-) diff --git a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt index 260071a3..280911f1 100644 --- a/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt +++ b/app/src/main/java/com/canopas/yourspace/YourSpaceApplication.kt @@ -53,7 +53,7 @@ class YourSpaceApplication : super.onCreate() Timber.plant(Timber.DebugTree()) - FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = false + FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = !BuildConfig.DEBUG ProcessLifecycleOwner.get().lifecycle.addObserver(this) authService.addListener(this) setNotificationChannel() 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 673e2a45..b1c3c502 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 @@ -12,15 +12,21 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember 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.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 @@ -29,9 +35,14 @@ import com.canopas.yourspace.ui.component.PrimaryButton fun EnterPinScreen() { Scaffold( topBar = { - TopAppBar(title = { Text("Enter Your PIN") }) + TopAppBar( + title = { Text(stringResource(R.string.enter_pin_top_bar_title)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) }, - contentColor = MaterialTheme.colorScheme.background + containerColor = MaterialTheme.colorScheme.background ) { EnterPinContent(modifier = Modifier.padding(it)) } @@ -41,6 +52,10 @@ fun EnterPinScreen() { private fun EnterPinContent(modifier: Modifier) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() + val context = LocalContext.current + val invalidPinText by remember { + mutableStateOf(context.getString(R.string.enter_pin_error_text)) + } Column( modifier = modifier @@ -50,16 +65,20 @@ private fun EnterPinContent(modifier: Modifier) { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Please enter your 4-digit PIN to access your account", + text = stringResource(R.string.enter_pin_header_text_part_one), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), textAlign = TextAlign.Center ) Text( - text = "Your PIN ensures that only you can access your account", + text = stringResource(R.string.enter_pin_header_text_part_two), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), textAlign = TextAlign.Center ) @@ -81,9 +100,9 @@ private fun EnterPinContent(modifier: Modifier) { Spacer(modifier = Modifier.height(24.dp)) PrimaryButton( - label = "Continue", + label = stringResource(R.string.enter_pin_continue_button_text), onClick = { - viewModel.processPin() + viewModel.processPin(invalidPinText) }, enabled = state.pin != "" && state.pinError == "", modifier = Modifier.fillMaxWidth(), 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 19b171c9..a08521dd 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 @@ -32,20 +32,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 = "") - } - } - - private fun validatePin(newPin: String) { - _state.value = _state.value.copy( - pinError = - when { - newPin.length < 4 -> "Pin must be at least 4 characters" - !newPin.all { it.isDigit() } -> "PIN must contain only digits" - else -> "" - } - ) + _state.value = _state.value.copy(pinError = if (newPin.length == 4) "" else null) } fun checkInternetConnection() { @@ -60,10 +47,9 @@ class EnterPinViewModel @Inject constructor( } } - fun processPin() = viewModelScope.launch(appDispatcher.MAIN) { + fun processPin(invalidPinText: String) = viewModelScope.launch(appDispatcher.MAIN) { _state.value = _state.value.copy(showLoader = true) val pin = state.value.pin - validatePin(pin) if (pin.length == 4) { val isPinValid = authService.validatePasskey(passKey = pin) if (isPinValid) { @@ -74,10 +60,8 @@ class EnterPinViewModel @Inject constructor( inclusive = true ) } else { - _state.value = _state.value.copy(pinError = "Invalid Pin") + _state.value = _state.value.copy(pinError = invalidPinText) } - } else { - _state.value = _state.value.copy(pinError = "Pin must be 4 characters") } } } @@ -85,7 +69,6 @@ class EnterPinViewModel @Inject constructor( data class EnterPinScreenState( val showLoader: Boolean = false, val pin: String = "", - val confirmPin: String = "", val pinError: String? = null, val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available, val error: Exception? = null 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 2cde40ea..6525c28b 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 @@ -12,15 +12,21 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember 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.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 @@ -29,9 +35,14 @@ import com.canopas.yourspace.ui.component.PrimaryButton fun SetPinScreen() { Scaffold( topBar = { - TopAppBar(title = { Text("Set Your PIN") }) + TopAppBar( + title = { Text(stringResource(R.string.set_pin_top_bar_title)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.background + ) + ) }, - contentColor = MaterialTheme.colorScheme.background + containerColor = MaterialTheme.colorScheme.background ) { SetPinContent(modifier = Modifier.padding(it)) } @@ -41,6 +52,10 @@ fun SetPinScreen() { private fun SetPinContent(modifier: Modifier) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() + val context = LocalContext.current + val lengthErrorText by remember { + mutableStateOf(context.getString(R.string.set_pin_error_text_length)) + } Column( modifier = modifier @@ -50,16 +65,20 @@ private fun SetPinContent(modifier: Modifier) { horizontalAlignment = Alignment.CenterHorizontally ) { Text( - text = "Secure your account by setting a 4-digit PIN", + text = stringResource(R.string.set_pin_header_text_part_one), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), textAlign = TextAlign.Center ) Text( - text = "Your PIN ensures that only you can access your account", + text = stringResource(R.string.set_pin_header_text_part_two), style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.fillMaxWidth().padding(bottom = 24.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), textAlign = TextAlign.Center ) @@ -81,9 +100,9 @@ private fun SetPinContent(modifier: Modifier) { Spacer(modifier = Modifier.height(24.dp)) PrimaryButton( - label = "Set Pin", + label = stringResource(R.string.set_pin_button_text), onClick = { - viewModel.processPin() + viewModel.processPin(lengthErrorText) }, enabled = state.pin != "" && state.pinError == "", modifier = Modifier.fillMaxWidth(), 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 7afbfb74..35c41929 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 @@ -40,17 +40,6 @@ class SetPinViewModel @Inject constructor( } } - private fun validatePin(newPin: String) { - _state.value = _state.value.copy( - pinError = - when { - newPin.length < 4 -> "Pin must be at least 4 characters" - !newPin.all { it.isDigit() } -> "PIN must contain only digits" - else -> "" - } - ) - } - fun checkInternetConnection() { viewModelScope.launch(appDispatcher.IO) { connectivityObserver.observe().collectLatest { status -> @@ -63,10 +52,13 @@ class SetPinViewModel @Inject constructor( } } - fun processPin() = viewModelScope.launch(appDispatcher.MAIN) { + fun processPin(lengthError: String) = viewModelScope.launch(appDispatcher.MAIN) { _state.value = _state.value.copy(showLoader = true) val pin = state.value.pin - validatePin(pin) + if (pin.length < 4) { + _state.value = _state.value.copy(pinError = lengthError) + return@launch + } if (pin.length == 4) { authService.generateAndSaveUserKeys(passKey = pin) val userId = authService.getUser()?.id diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fb4fd06a..683f00f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -307,4 +307,17 @@ Reassign Admin Before Deletion Please assign a new admin before proceeding with the account deletion to avoid data loss. \nYou are an admin of the following groups :\n%1$s + + Set Your PIN + 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 + + 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 + Continue + \ No newline at end of file diff --git a/app/src/test/java/com/canopas/yourspace/ui/flow/auth/methods/SignInMethodViewModelTest.kt b/app/src/test/java/com/canopas/yourspace/ui/flow/auth/methods/SignInMethodViewModelTest.kt index dc00e1fa..8437b43f 100644 --- a/app/src/test/java/com/canopas/yourspace/ui/flow/auth/methods/SignInMethodViewModelTest.kt +++ b/app/src/test/java/com/canopas/yourspace/ui/flow/auth/methods/SignInMethodViewModelTest.kt @@ -2,6 +2,7 @@ package com.canopas.yourspace.ui.flow.auth.methods import androidx.arch.core.executor.testing.InstantTaskExecutorRule import com.canopas.yourspace.MainCoroutineRule +import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.service.auth.AuthService import com.canopas.yourspace.data.service.auth.FirebaseAuthService import com.canopas.yourspace.data.storage.UserPreferences @@ -42,10 +43,12 @@ class SignInMethodViewModelTest { private val authService = mock() private val userPreferences = mock() private val connectivityObserver = mock() + private val currentUser = ApiUser(first_name = "first", last_name = "last") @Before fun setup() { whenever(connectivityObserver.observe()).thenReturn(flowOf(ConnectivityObserver.Status.Available)) + whenever(authService.currentUser).thenReturn(currentUser) viewModel = SignInMethodViewModel( navigator, @@ -82,20 +85,6 @@ class SignInMethodViewModelTest { verify(authService).verifiedGoogleLogin("uid", "firebaseToken", account) } - @Test - fun `proceedGoogleSignIn should navigate to home screen`() = runTest { - val account = mock() - whenever(account.idToken).thenReturn("token") - whenever(firebaseAuth.signInWithGoogleAuthCredential("token")) - .thenReturn("firebaseToken") - whenever(firebaseAuth.currentUserUid).thenReturn("uid") - - whenever(authService.verifiedGoogleLogin("uid", "firebaseToken", account)) - .thenReturn(false) - viewModel.proceedGoogleSignIn(account) - verify(navigator).navigateTo("home", "sign-in", true) - } - @Test fun `proceedGoogleSignIn should navigate to onboard screen`() = runTest { val account = mock() @@ -107,7 +96,7 @@ class SignInMethodViewModelTest { whenever(authService.verifiedGoogleLogin("uid", "firebaseToken", account)) .thenReturn(true) viewModel.proceedGoogleSignIn(account) - verify(navigator).navigateTo("onboard", "sign-in", true) + verify(navigator).navigateTo("set-pin", "sign-in", true) } @Test 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 a8d5c53c..fa465980 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 @@ -3,6 +3,7 @@ package com.canopas.yourspace.data.models.location import android.location.Location import androidx.annotation.Keep import com.google.android.gms.maps.model.LatLng +import com.google.firebase.firestore.Blob import com.squareup.moshi.JsonClass import java.util.UUID @@ -27,13 +28,13 @@ data class LocationJourney( data class EncryptedLocationJourney( val id: String = UUID.randomUUID().toString(), val user_id: String = "", - val encrypted_from_latitude: String = "", // Base64 encoded encrypted latitude - from - val encrypted_from_longitude: String = "", // Base64 encoded encrypted longitude - from - val encrypted_to_latitude: String? = "", // Base64 encoded encrypted latitude - to - val encrypted_to_longitude: String? = "", // Base64 encoded encrypted longitude - to + val encrypted_from_latitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted latitude - from + val encrypted_from_longitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted longitude - from + val encrypted_to_latitude: Blob? = null, // Encrypted latitude - to + val encrypted_to_longitude: Blob? = null, // Encrypted longitude - to val route_distance: Double? = null, val route_duration: Long? = null, - val encrypted_routes: List = emptyList(), // Base64 encoded encrypted journey routes + val encrypted_routes: List = emptyList(), // Encrypted journey routes val created_at: Long? = System.currentTimeMillis(), val updated_at: Long? = System.currentTimeMillis() ) @@ -44,8 +45,8 @@ data class JourneyRoute(val latitude: Double = 0.0, val longitude: Double = 0.0) @Keep @JsonClass(generateAdapter = true) data class EncryptedJourneyRoute( - val encrypted_latitude: String = "", // Base64 encoded encrypted latitude - val encrypted_longitude: String = "" // Base64 encoded encrypted longitude + val encrypted_latitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted latitude + val encrypted_longitude: Blob = Blob.fromBytes(ByteArray(0)) // Encrypted longitude ) fun Location.toRoute(): JourneyRoute { 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 14a6f146..19ad39be 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 @@ -1,41 +1,94 @@ 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.canopas.yourspace.data.models.space.SenderKeyDistribution +import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.storage.UserPreferences +import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS +import com.canopas.yourspace.data.utils.EphemeralECDHUtils +import com.canopas.yourspace.data.utils.PrivateKeyUtils +import com.google.firebase.firestore.Blob import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.toObject import kotlinx.coroutines.tasks.await +import org.signal.libsignal.protocol.InvalidKeyException +import org.signal.libsignal.protocol.SignalProtocolAddress +import org.signal.libsignal.protocol.ecc.Curve +import org.signal.libsignal.protocol.ecc.ECPrivateKey +import org.signal.libsignal.protocol.groups.GroupCipher +import org.signal.libsignal.protocol.groups.GroupSessionBuilder +import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage import timber.log.Timber +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @Singleton class ApiJourneyService @Inject constructor( db: FirebaseFirestore, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val bufferedSenderKeyStore: BufferedSenderKeyStore ) { - var currentSpaceId: String = userPreferences.currentSpace ?: "" - - // App crashes sometimes because of the empty userId string passed to document(). - // java.lang.IllegalArgumentException: Invalid document reference. - // Document references must have an even number of segments, but users has 1 - // https://stackoverflow.com/a/51195713/22508023 [Explanation can be found in comments] private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) - internal fun spaceMemberRef(spaceId: String) = - spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( - FIRESTORE_COLLECTION_SPACE_MEMBERS - ) + + private fun spaceMemberRef(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") + .collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) private fun spaceMemberJourneyRef(spaceId: String, userId: String) = - spaceMemberRef(spaceId) - .document(userId.takeIf { it.isNotBlank() } ?: "null") + spaceMemberRef(spaceId).document(userId.takeIf { it.isNotBlank() } ?: "null") .collection(Config.FIRESTORE_COLLECTION_USER_JOURNEYS) + private fun spaceGroupKeysRef(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") + .collection(Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) + + private suspend fun getGroupCipher(spaceId: String, userId: String): GroupCipher? { + val senderKeyDistributionRef = spaceGroupKeysRef(spaceId).document(userId).get().await() + val senderKeyDistribution = + senderKeyDistributionRef.toObject(SenderKeyDistribution::class.java) ?: return null + + val currentUser = userPreferences.currentUser ?: return null + + val privateKey = getCurrentUserPrivateKey(currentUser) ?: return null + + val distribution = + senderKeyDistribution.distributions.firstOrNull { it.recipientId == currentUser.id } + ?: return null + val decryptedDistributionBytes = + EphemeralECDHUtils.decrypt(distribution, privateKey) ?: return null + val distributionMessage = SenderKeyDistributionMessage(decryptedDistributionBytes) + + val groupAddress = SignalProtocolAddress(spaceId, senderKeyDistribution.senderDeviceId) + val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) + sessionBuilder.process(groupAddress, distributionMessage) + + return GroupCipher(bufferedSenderKeyStore, groupAddress) + } + + /** + * Decrypts and retrieves the current user's private key. + */ + private suspend fun getCurrentUserPrivateKey(currentUser: ApiUser): ECPrivateKey? { + return try { + Curve.decodePrivatePoint(currentUser.identity_key_private?.toBytes()) + } catch (e: InvalidKeyException) { + Timber.e(e, "Error decoding private key for userId=${currentUser.id}") + PrivateKeyUtils.decryptPrivateKey( + currentUser.identity_key_private?.toBytes() ?: return null, + currentUser.identity_key_salt?.toBytes() ?: return null, + userPreferences.getPasskey() ?: return null + )?.let { Curve.decodePrivatePoint(it) } + } + } + suspend fun saveCurrentJourney( userId: String, fromLatitude: Double, @@ -49,11 +102,14 @@ class ApiJourneyService @Inject constructor( updateAt: Long? = null, newJourneyId: ((String) -> Unit)? = null ) { - userPreferences.currentUser?.space_ids?.forEach { - val docRef = spaceMemberJourneyRef(it, userId).document() + userPreferences.currentUser?.space_ids?.forEach { spaceId -> + val groupCipher = getGroupCipher(spaceId, userId) ?: run { + Timber.e("Failed to retrieve GroupCipher for spaceId: $spaceId, userId: $userId") + return@forEach + } val journey = LocationJourney( - id = docRef.id, + id = UUID.randomUUID().toString(), user_id = userId, from_latitude = fromLatitude, from_longitude = fromLongitude, @@ -66,72 +122,227 @@ class ApiJourneyService @Inject constructor( update_at = updateAt ?: System.currentTimeMillis() ) - newJourneyId?.invoke(journey.id) + val docRef = spaceMemberJourneyRef(spaceId, userId).document(journey.id) + + val encryptedJourney = journey.toEncryptedLocationJourney(groupCipher) + newJourneyId?.invoke(encryptedJourney.id) + + docRef.set(encryptedJourney).await() + } + } + + private fun LocationJourney.toEncryptedLocationJourney(groupCipher: GroupCipher): EncryptedLocationJourney { + val encryptedFromLat = groupCipher.encrypt( + UUID.fromString(id), + from_latitude.toString().toByteArray(Charsets.UTF_8) + ) + val encryptedFromLong = groupCipher.encrypt( + UUID.fromString(id), + from_longitude.toString().toByteArray(Charsets.UTF_8) + ) + val encryptedToLat = to_latitude?.let { + groupCipher.encrypt( + UUID.fromString(id), + it.toString().toByteArray(Charsets.UTF_8) + ) + } + val encryptedToLong = to_longitude?.let { + groupCipher.encrypt( + UUID.fromString(id), + it.toString().toByteArray(Charsets.UTF_8) + ) + } + + val encryptedRoutes = routes.map { + EncryptedJourneyRoute( + encrypted_latitude = Blob.fromBytes( + groupCipher.encrypt( + UUID.fromString(id), + it.latitude.toString().toByteArray(Charsets.UTF_8) + ).serialize() + ), + encrypted_longitude = Blob.fromBytes( + groupCipher.encrypt( + UUID.fromString(id), + it.longitude.toString().toByteArray(Charsets.UTF_8) + ).serialize() + ) + ) + } + + return EncryptedLocationJourney( + id = id, + user_id = user_id, + encrypted_from_latitude = Blob.fromBytes(encryptedFromLat.serialize()), + encrypted_from_longitude = Blob.fromBytes(encryptedFromLong.serialize()), + encrypted_to_latitude = encryptedToLat?.let { Blob.fromBytes(it.serialize()) }, + encrypted_to_longitude = encryptedToLong?.let { Blob.fromBytes(it.serialize()) }, + route_distance = route_distance, + route_duration = route_duration, + encrypted_routes = encryptedRoutes, + created_at = created_at, + updated_at = update_at + ) + } + + private fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher): LocationJourney { + val decryptedFromLat = groupCipher.decrypt(encrypted_from_latitude.toBytes()) + val decryptedFromLong = groupCipher.decrypt(encrypted_from_longitude.toBytes()) + val decryptedToLat = encrypted_to_latitude?.let { groupCipher.decrypt(it.toBytes()) } + val decryptedToLong = encrypted_to_longitude?.let { groupCipher.decrypt(it.toBytes()) } - docRef.set(journey).await() + val decryptedRoutes = encrypted_routes.map { + JourneyRoute( + latitude = groupCipher.decrypt(it.encrypted_latitude.toBytes()) + .toString(Charsets.UTF_8).toDouble(), + longitude = groupCipher.decrypt(it.encrypted_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, + update_at = updated_at + ) } suspend fun updateLastLocationJourney(userId: String, journey: LocationJourney) { - try { - userPreferences.currentUser?.space_ids?.forEach { - spaceMemberJourneyRef(it, userId).document(journey.id).set(journey).await() + userPreferences.currentUser?.space_ids?.forEach { spaceId -> + val groupCipher = getGroupCipher(spaceId, userId) ?: run { + Timber.e("Failed to retrieve GroupCipher for spaceId: $spaceId, userId: $userId") + return@forEach + } + + val encryptedJourney = journey.toEncryptedLocationJourney(groupCipher) + try { + 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" + ) } - } catch (e: Exception) { - Timber.e(e, "Error while updating last location journey") } } - suspend fun getLastJourneyLocation(userId: String) = try { - spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) - .orderBy("created_at", Query.Direction.DESCENDING).limit(1) - .get().await().documents.firstOrNull()?.toObject() - } catch (e: Exception) { - Timber.e(e, "Error while getting last location journey") - null + suspend fun getLastJourneyLocation(userId: String): LocationJourney? { + val currentSpaceId = userPreferences.currentSpace ?: return null + val groupCipher = getGroupCipher(currentSpaceId, userId) ?: run { + Timber.e("Failed to retrieve GroupCipher for spaceId: $currentSpaceId, userId: $userId") + return null + } + + return try { + spaceMemberJourneyRef(currentSpaceId, userId) + .whereEqualTo("user_id", userId) + .orderBy("created_at", Query.Direction.DESCENDING) + .limit(1) + .get() + .await() + .documents + .firstOrNull() + ?.toObject() + ?.toDecryptedLocationJourney(groupCipher) + } catch (e: Exception) { + Timber.e(e, "Error while getting last location journey for userId: $userId") + null + } } - suspend fun getMoreJourneyHistory( - userId: String, - from: Long? - ): List { + suspend fun getMoreJourneyHistory(userId: String, from: Long?): List { + val currentSpaceId = userPreferences.currentSpace ?: return emptyList() + val groupCipher = getGroupCipher(currentSpaceId, userId) ?: run { + Timber.e("Failed to retrieve GroupCipher for spaceId: $currentSpaceId, userId: $userId") + return emptyList() + } + val query = if (from == null) { - spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId, userId) + .whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) .limit(20) } else { - spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) + spaceMemberJourneyRef(currentSpaceId, userId) + .whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) .whereLessThan("created_at", from) .limit(20) } - return query.get().await().documents.mapNotNull { it.toObject() } + + 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() + } } - suspend fun getJourneyHistory( - userId: String, - from: Long, - to: Long - ): List { - val previousDayJourney = spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) - .whereLessThan("created_at", from) - .whereGreaterThanOrEqualTo("update_at", from) - .limit(1) - .get().await().documents.mapNotNull { it.toObject() } - - val currentDayJourney = spaceMemberJourneyRef(currentSpaceId, userId).whereEqualTo("user_id", userId) - .whereGreaterThanOrEqualTo("created_at", from) - .whereLessThanOrEqualTo("created_at", to) - .orderBy("created_at", Query.Direction.DESCENDING) - .limit(20) - .get().await().documents.mapNotNull { it.toObject() } - - return previousDayJourney + currentDayJourney + suspend fun getJourneyHistory(userId: String, from: Long, to: Long): List { + val currentSpaceId = userPreferences.currentSpace ?: return emptyList() + val groupCipher = getGroupCipher(currentSpaceId, userId) ?: run { + Timber.e("Failed to retrieve GroupCipher for spaceId: $currentSpaceId, userId: $userId") + return emptyList() + } + + return try { + val previousDayJourney = spaceMemberJourneyRef(currentSpaceId, userId) + .whereEqualTo("user_id", userId) + .whereLessThan("created_at", from) + .whereGreaterThanOrEqualTo("update_at", from) + .limit(1) + .get() + .await() + .documents + .mapNotNull { + it.toObject()?.toDecryptedLocationJourney(groupCipher) + } + + val currentDayJourney = spaceMemberJourneyRef(currentSpaceId, userId) + .whereEqualTo("user_id", userId) + .whereGreaterThanOrEqualTo("created_at", from) + .whereLessThanOrEqualTo("created_at", to) + .orderBy("created_at", Query.Direction.DESCENDING) + .limit(20) + .get() + .await() + .documents + .mapNotNull { + it.toObject()?.toDecryptedLocationJourney(groupCipher) + } + + previousDayJourney + currentDayJourney + } catch (e: Exception) { + Timber.e(e, "Error while getting journey history for userId: $userId") + emptyList() + } } suspend fun getLocationJourneyFromId(journeyId: String): LocationJourney? { - val userId = userPreferences.currentUser?.id ?: return null - return spaceMemberJourneyRef(currentSpaceId, userId).document(journeyId).get().await() - .toObject(LocationJourney::class.java) + val currentSpaceId = userPreferences.currentSpace ?: return null + val currentUser = userPreferences.currentUser ?: return null + val groupCipher = getGroupCipher(currentSpaceId, currentUser.id) ?: return null + + return 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 + } } } 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 e8d1a424..7b72d6df 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 @@ -44,13 +44,16 @@ class ApiLocationService @Inject constructor( private val spaceRef by lazy { db.collection(FIRESTORE_COLLECTION_SPACES) } private fun spaceMemberRef(spaceId: String) = - spaceRef.document(spaceId).collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") + .collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) private fun spaceMemberLocationRef(spaceId: String, userId: String) = - spaceMemberRef(spaceId).document(userId).collection(Config.FIRESTORE_COLLECTION_USER_LOCATIONS) + spaceMemberRef(spaceId.takeIf { it.isNotBlank() } ?: "null").document(userId) + .collection(Config.FIRESTORE_COLLECTION_USER_LOCATIONS) private fun spaceGroupKeysRef(spaceId: String) = - spaceRef.document(spaceId).collection(Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") + .collection(Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) suspend fun saveLastKnownLocation(userId: String) { val lastLocation = locationManager.getLastLocation() ?: return @@ -125,7 +128,10 @@ class ApiLocationService @Inject constructor( try { spaceMemberLocationRef(spaceId, userId).document(location.id).set(location).await() } catch (e: Exception) { - Timber.e(e, "Failed to save encrypted location for userId: $userId in spaceId: $spaceId") + Timber.e( + e, + "Failed to save encrypted location for userId: $userId in spaceId: $spaceId" + ) } } @@ -149,12 +155,17 @@ class ApiLocationService @Inject constructor( } } - private suspend fun decryptLocation(encryptedLocation: EncryptedApiLocation, userId: String): ApiLocation? { - val groupCipher = getGroupCipherAndDistributionMessage(currentSpaceId, userId)?.second ?: return null + private suspend fun decryptLocation( + encryptedLocation: EncryptedApiLocation, + userId: String + ): ApiLocation? { + val groupCipher = + getGroupCipherAndDistributionMessage(currentSpaceId, userId)?.second ?: return null return try { val latitudeBytes = groupCipher.decrypt(encryptedLocation.encrypted_latitude.toBytes()) - val longitudeBytes = groupCipher.decrypt(encryptedLocation.encrypted_longitude.toBytes()) + val longitudeBytes = + groupCipher.decrypt(encryptedLocation.encrypted_longitude.toBytes()) ApiLocation( id = encryptedLocation.id, @@ -201,7 +212,11 @@ class ApiLocationService @Inject constructor( senderDeviceId = senderKeyDistribution.senderDeviceId ) // Retry fetching the distribution after distribution - return getGroupCipherAndDistributionMessage(spaceId, userId, canDistributeSenderKey = false) + return getGroupCipherAndDistributionMessage( + spaceId, + userId, + canDistributeSenderKey = false + ) } null } ?: return null @@ -283,7 +298,10 @@ class ApiLocationService @Inject constructor( .await() Timber.d("Sender key distribution uploaded for new member: ${newMember.user_id} in spaceId=$spaceId.") } catch (e: Exception) { - Timber.e(e, "Failed to upload sender key distribution for new member userId=${newMember.user_id} in spaceId=$spaceId") + Timber.e( + e, + "Failed to upload sender key distribution for new member userId=${newMember.user_id} in spaceId=$spaceId" + ) } } } 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 fdaa5431..67b2fbea 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 @@ -43,10 +43,10 @@ class BufferedSenderKeyStore @Inject constructor( private val inMemoryStore: MutableMap = mutableMapOf() private val sharedWithAddresses: MutableSet = mutableSetOf() - private suspend fun saveSenderKeyToServer(distributionId: UUID, senderKeyRecord: ApiSenderKeyRecord) { + private suspend fun saveSenderKeyToServer(senderKeyRecord: ApiSenderKeyRecord) { val currentUser = userPreferences.currentUser ?: return - spaceSenderKeyRecordRef(distributionId.toString(), currentUser.id) - .document(distributionId.toString()).set(senderKeyRecord).await() + spaceSenderKeyRecordRef(senderKeyRecord.distributionId, currentUser.id) + .document(senderKeyRecord.distributionId).set(senderKeyRecord).await() } override fun storeSenderKey( @@ -77,7 +77,7 @@ class BufferedSenderKeyStore @Inject constructor( distributionId = distributionId.toString(), record = Blob.fromBytes(record.serialize()) ) - saveSenderKeyToServer(distributionId, senderKeyRecord) + saveSenderKeyToServer(senderKeyRecord) } } @@ -91,16 +91,16 @@ class BufferedSenderKeyStore @Inject constructor( )?.let { inMemoryStore[key] = it it - } ?: fetchSenderKeyFromServer(distributionId)?.also { + } ?: fetchSenderKeyFromServer(sender)?.also { inMemoryStore[key] = it } } } - private suspend fun fetchSenderKeyFromServer(distributionId: UUID): SenderKeyRecord? { + private suspend fun fetchSenderKeyFromServer(sender: SignalProtocolAddress): SenderKeyRecord? { val currentUser = userPreferences.currentUser ?: return null - return spaceSenderKeyRecordRef(distributionId.toString(), currentUser.id) - .document(distributionId.toString()).get().await().toObject(ApiSenderKeyRecord::class.java)?.let { + return spaceSenderKeyRecordRef(sender.name.toString(), currentUser.id) + .document(sender.name.toString()).get().await().toObject(ApiSenderKeyRecord::class.java)?.let { try { SenderKeyRecord(it.record.toBytes()) } catch (e: Exception) { diff --git a/firestore.rules b/firestore.rules index e42b1d39..030a1eb9 100644 --- a/firestore.rules +++ b/firestore.rules @@ -133,31 +133,20 @@ service cloud.firestore { match /spaces/{spaceId}/group_keys/{senderId} { allow read: if isAuthorized() && isSpaceMember(spaceId); - allow create: if isAuthorized() && - request.auth.uid == senderId && - isSpaceMember(spaceId) && - request.resource.data.keys().hasAll(["senderId", "distributions", "createdAt"]) && - request.resource.data.senderId is string && - request.resource.data.distributions is list && - request.resource.data.createdAt is int; - + request.auth.uid == senderId && + isSpaceMember(spaceId) && + request.resource.data.keys().hasAll(["senderId", "distributions", "createdAt"]) && + request.resource.data.senderId is string && + request.resource.data.distributions is list && + request.resource.data.createdAt is int; allow update: if isAuthorized() && isSpaceMember(spaceId); allow delete: if isAuthorized() && isSpaceMember(spaceId); } - match /spaces/{spaceId}/space_members/{userId}/sender_key_record/{docId} { - allow read: if isAuthorized() && isSpaceMember(spaceId); - allow write: if isAuthorized() && request.auth.uid == userId && isSpaceMember(spaceId); - allow delete: if false; // Disallow deletion for safety, adjust if needed. - allow create: if isAuthorized() && - request.auth.uid == userId && - isSpaceMember(spaceId) && - request.resource.data.keys().hasAll(["address", "deviceId", "distributionId", "record"]) && - request.resource.data.address is string && - request.resource.data.deviceId is int && - request.resource.data.distributionId is string && - request.resource.data.record is bytes; + match /{path=**}/space_places/{place} { + allow read: if isAuthorized() && isSpaceMember(resource.data.space_id); + allow write: if false; } match /spaces/{spaceId}/space_places/{place} { @@ -165,10 +154,9 @@ service cloud.firestore { allow delete: if isAuthorized() && (isSpaceAdmin(resource.data.space_id) || request.auth.uid == resource.data.created_by); allow update: if isAuthorized() && - (request.auth.uid == resource.data.created_by || - (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["space_member_ids"]) && - request.resource.data.space_member_ids.hasAny([request.auth.uid]) - )); + (request.auth.uid == resource.data.created_by || + (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["space_member_ids"]) && + request.resource.data.space_member_ids.hasAny([request.auth.uid]))); allow create: if isAuthorized() && (isSpaceAdmin(request.resource.data.space_id) || request.auth.uid == request.resource.data.created_by) && request.resource.data.keys().hasAll(["id", "space_id", "created_by", "latitude", "longitude", "radius", "name", "created_at"]) && @@ -241,7 +229,14 @@ service cloud.firestore { (request.auth.uid == resource.data.user_id || readUserLocation()); allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; - allow create: if isAuthorized(); + allow create: if isAuthorized() && + request.auth.uid == request.resource.data.user_id && + request.resource.data.keys().hasAll(["id", "user_id", "encrypted_latitude", "encrypted_longitude", "created_at"]) && + request.resource.data.id is string && + request.resource.data.user_id is string && + request.resource.data.encrypted_latitude is bytes && + request.resource.data.encrypted_longitude is bytes && + request.resource.data.created_at is int; } match /user_journeys/{docId} { @@ -251,13 +246,27 @@ service cloud.firestore { allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && + request.resource.data.keys().hasAll(["id", "user_id", "encrypted_from_latitude", "encrypted_from_longitude", "created_at"]) && request.resource.data.id is string && request.resource.data.user_id is string && - request.resource.data.from_latitude is number && - request.resource.data.from_longitude is number && + request.resource.data.encrypted_from_latitude is bytes && + request.resource.data.encrypted_from_longitude is bytes && request.resource.data.created_at is int; } + + match /sender_key_record/{docId} { + allow read: if isAuthorized() && isSpaceMember(spaceId); + allow write: if isAuthorized() && isSpaceMember(spaceId); + allow delete: if false; + allow create: if isAuthorized() && + request.auth.uid == request.resource.data.user_id && + isSpaceMember(spaceId) && + request.resource.data.keys().hasAll(["address", "deviceId", "distributionId", "record"]) && + request.resource.data.address is string && + request.resource.data.deviceId is int && + request.resource.data.distributionId is string && + request.resource.data.record is bytes; + } } match /space_invitations/{docId} { From 9f4413cc1eb498ccc12d6cee88d86844a7cf7dec Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 2 Jan 2025 14:13:04 +0530 Subject: [PATCH 12/30] live updates pending --- .../ui/flow/onboard/OnboardViewModel.kt | 4 - .../yourspace/data/models/space/ApiSpace.kt | 15 +- .../service/location/ApiJourneyService.kt | 91 +++++++----- .../service/location/ApiLocationService.kt | 129 +++++++++--------- .../data/service/space/ApiSpaceService.kt | 41 ++++-- .../BufferedSenderKeyStore.kt | 35 +++-- firestore.rules | 10 +- 7 files changed, 190 insertions(+), 135 deletions(-) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/onboard/OnboardViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/onboard/OnboardViewModel.kt index dae356aa..8a3e6c61 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/onboard/OnboardViewModel.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/onboard/OnboardViewModel.kt @@ -36,10 +36,6 @@ class OnboardViewModel @Inject constructor( private val currentUser get() = authService.currentUser init { - Timber.e( - "XXXXXX:\n" + - "identity_key_public: ${userPreferences.currentUser?.identity_key_public?.toBytes()?.size}\n" - ) checkInternetConnection() val user = authService.currentUser _state.value = _state.value.copy( 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 98666176..445f3e77 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 @@ -49,14 +49,23 @@ data class ApiSpaceInvitation( } /** + * Group key document structure for a single space. + */ +@Keep +data class GroupKeysDoc( + val docUpdatedAt: Long = System.currentTimeMillis(), // To be updated whenever users are added/removed + val senderKeys: Map = emptyMap() +) + +/* * Data class that represents the entire "groupKeys/{senderUserId}" doc * in Firestore for a single sender's key distribution. */ -data class SenderKeyDistribution( - val senderId: String = "", +@Keep +data class SenderKeyData( val senderDeviceId: Int = 0, val distributions: List = emptyList(), - val createdAt: Long = 0 + val dataUpdatedAt: Long = System.currentTimeMillis() // To be updated whenever a new distribution is added ) /** 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 19ad39be..9ea15d2b 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 @@ -4,12 +4,13 @@ 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.canopas.yourspace.data.models.space.SenderKeyDistribution +import com.canopas.yourspace.data.models.space.GroupKeysDoc import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.canopas.yourspace.data.utils.EphemeralECDHUtils import com.canopas.yourspace.data.utils.PrivateKeyUtils @@ -48,29 +49,40 @@ class ApiJourneyService @Inject constructor( private fun spaceGroupKeysRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") - .collection(Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) + .collection(FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) + .document(FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) - private suspend fun getGroupCipher(spaceId: String, userId: String): GroupCipher? { - val senderKeyDistributionRef = spaceGroupKeysRef(spaceId).document(userId).get().await() - val senderKeyDistribution = - senderKeyDistributionRef.toObject(SenderKeyDistribution::class.java) ?: return null + private suspend fun getGroupCipherAndDistributionMessage( + spaceId: String, + userId: String + ): Pair? { + val snapshot = spaceGroupKeysRef(spaceId).get().await() + val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: return null + val senderKeyData = groupKeysDoc.senderKeys[userId] ?: return null val currentUser = userPreferences.currentUser ?: return null - val privateKey = getCurrentUserPrivateKey(currentUser) ?: return null val distribution = - senderKeyDistribution.distributions.firstOrNull { it.recipientId == currentUser.id } + senderKeyData.distributions.firstOrNull { it.recipientId == currentUser.id } ?: return null val decryptedDistributionBytes = EphemeralECDHUtils.decrypt(distribution, privateKey) ?: return null val distributionMessage = SenderKeyDistributionMessage(decryptedDistributionBytes) - val groupAddress = SignalProtocolAddress(spaceId, senderKeyDistribution.senderDeviceId) - val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) - sessionBuilder.process(groupAddress, distributionMessage) + val groupAddress = SignalProtocolAddress(spaceId, senderKeyData.senderDeviceId) + bufferedSenderKeyStore.loadSenderKey(groupAddress, distributionMessage.distributionId) + + // Initialize the session + try { + GroupSessionBuilder(bufferedSenderKeyStore).process(groupAddress, distributionMessage) + } catch (e: Exception) { + Timber.e(e, "Error processing group session for spaceId=$spaceId, userId=$userId") + return null + } - return GroupCipher(bufferedSenderKeyStore, groupAddress) + val groupCipher = GroupCipher(bufferedSenderKeyStore, groupAddress) + return Pair(distributionMessage, groupCipher) } /** @@ -103,10 +115,11 @@ class ApiJourneyService @Inject constructor( newJourneyId: ((String) -> Unit)? = null ) { userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val groupCipher = getGroupCipher(spaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher for spaceId: $spaceId, userId: $userId") + 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 journey = LocationJourney( id = UUID.randomUUID().toString(), @@ -124,31 +137,35 @@ class ApiJourneyService @Inject constructor( val docRef = spaceMemberJourneyRef(spaceId, userId).document(journey.id) - val encryptedJourney = journey.toEncryptedLocationJourney(groupCipher) + val encryptedJourney = + journey.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) newJourneyId?.invoke(encryptedJourney.id) docRef.set(encryptedJourney).await() } } - private fun LocationJourney.toEncryptedLocationJourney(groupCipher: GroupCipher): EncryptedLocationJourney { + private fun LocationJourney.toEncryptedLocationJourney( + groupCipher: GroupCipher, + distributionId: UUID + ): EncryptedLocationJourney { val encryptedFromLat = groupCipher.encrypt( - UUID.fromString(id), + distributionId, from_latitude.toString().toByteArray(Charsets.UTF_8) ) val encryptedFromLong = groupCipher.encrypt( - UUID.fromString(id), + distributionId, from_longitude.toString().toByteArray(Charsets.UTF_8) ) val encryptedToLat = to_latitude?.let { groupCipher.encrypt( - UUID.fromString(id), + distributionId, it.toString().toByteArray(Charsets.UTF_8) ) } val encryptedToLong = to_longitude?.let { groupCipher.encrypt( - UUID.fromString(id), + distributionId, it.toString().toByteArray(Charsets.UTF_8) ) } @@ -157,13 +174,13 @@ class ApiJourneyService @Inject constructor( EncryptedJourneyRoute( encrypted_latitude = Blob.fromBytes( groupCipher.encrypt( - UUID.fromString(id), + distributionId, it.latitude.toString().toByteArray(Charsets.UTF_8) ).serialize() ), encrypted_longitude = Blob.fromBytes( groupCipher.encrypt( - UUID.fromString(id), + distributionId, it.longitude.toString().toByteArray(Charsets.UTF_8) ).serialize() ) @@ -217,12 +234,14 @@ class ApiJourneyService @Inject constructor( suspend fun updateLastLocationJourney(userId: String, journey: LocationJourney) { userPreferences.currentUser?.space_ids?.forEach { spaceId -> - val groupCipher = getGroupCipher(spaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher for spaceId: $spaceId, userId: $userId") + 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 encryptedJourney = journey.toEncryptedLocationJourney(groupCipher) + val encryptedJourney = + journey.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) try { spaceMemberJourneyRef(spaceId, userId).document(journey.id).set(encryptedJourney) .await() @@ -237,10 +256,11 @@ class ApiJourneyService @Inject constructor( suspend fun getLastJourneyLocation(userId: String): LocationJourney? { val currentSpaceId = userPreferences.currentSpace ?: return null - val groupCipher = getGroupCipher(currentSpaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher for spaceId: $currentSpaceId, userId: $userId") + val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, userId) ?: run { + Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: $userId") return null } + val (_, groupCipher) = cipherAndMessage return try { spaceMemberJourneyRef(currentSpaceId, userId) @@ -261,10 +281,11 @@ class ApiJourneyService @Inject constructor( suspend fun getMoreJourneyHistory(userId: String, from: Long?): List { val currentSpaceId = userPreferences.currentSpace ?: return emptyList() - val groupCipher = getGroupCipher(currentSpaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher for spaceId: $currentSpaceId, userId: $userId") + val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, userId) ?: run { + Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: $userId") return emptyList() } + val (_, groupCipher) = cipherAndMessage val query = if (from == null) { spaceMemberJourneyRef(currentSpaceId, userId) @@ -291,10 +312,11 @@ class ApiJourneyService @Inject constructor( suspend fun getJourneyHistory(userId: String, from: Long, to: Long): List { val currentSpaceId = userPreferences.currentSpace ?: return emptyList() - val groupCipher = getGroupCipher(currentSpaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher for spaceId: $currentSpaceId, userId: $userId") + val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, userId) ?: run { + Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: $userId") return emptyList() } + val (_, groupCipher) = cipherAndMessage return try { val previousDayJourney = spaceMemberJourneyRef(currentSpaceId, userId) @@ -332,7 +354,12 @@ class ApiJourneyService @Inject constructor( suspend fun getLocationJourneyFromId(journeyId: String): LocationJourney? { val currentSpaceId = userPreferences.currentSpace ?: return null val currentUser = userPreferences.currentUser ?: return null - val groupCipher = getGroupCipher(currentSpaceId, currentUser.id) ?: 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) 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 7b72d6df..b085016b 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,18 +3,19 @@ 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.SenderKeyDistribution +import com.canopas.yourspace.data.models.space.GroupKeysDoc +import com.canopas.yourspace.data.models.space.SenderKeyData import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore import com.canopas.yourspace.data.utils.Config import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES +import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS import com.canopas.yourspace.data.utils.EphemeralECDHUtils import com.canopas.yourspace.data.utils.PrivateKeyUtils import com.canopas.yourspace.data.utils.snapshotFlow import com.google.firebase.firestore.Blob -import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import kotlinx.coroutines.flow.Flow @@ -53,7 +54,8 @@ class ApiLocationService @Inject constructor( private fun spaceGroupKeysRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") - .collection(Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) + .collection(FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) + .document(FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) suspend fun saveLastKnownLocation(userId: String) { val lastLocation = locationManager.getLastLocation() ?: return @@ -180,6 +182,10 @@ class ApiLocationService @Inject constructor( } } + private suspend fun getSpaceMembers(spaceId: String): List { + return spaceMemberRef(spaceId).get().await().toObjects(ApiSpaceMember::class.java) + } + /** * Provide group cipher and sender key distribution message for a particular space and user. */ @@ -190,36 +196,13 @@ class ApiLocationService @Inject constructor( ): Pair? { val currentUser = userPreferences.currentUser ?: return null - val senderKeyDistribution = spaceGroupKeysRef(spaceId) - .document(userId) - .get() - .await() - .toObject(SenderKeyDistribution::class.java) ?: return null + val snapshot = spaceGroupKeysRef(spaceId).get().await() + val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: return null + val senderKeyData = groupKeysDoc.senderKeys[userId] ?: return null - val distribution = senderKeyDistribution.distributions - .firstOrNull { it.recipientId == currentUser.id } - ?: run { - if (canDistributeSenderKey) { - Timber.d("Distributing sender key to new member: userId=$userId in spaceId=$spaceId") - distributeSenderKeyToNewMember( - spaceId = spaceId, - senderUserId = senderKeyDistribution.senderId, - newMember = ApiSpaceMember( - user_id = userId, - space_id = spaceId, - identity_key_public = currentUser.identity_key_public - ), - senderDeviceId = senderKeyDistribution.senderDeviceId - ) - // Retry fetching the distribution after distribution - return getGroupCipherAndDistributionMessage( - spaceId, - userId, - canDistributeSenderKey = false - ) - } - null - } ?: return null + val distribution = senderKeyData.distributions.firstOrNull { + it.recipientId == currentUser.id + } ?: return null val currentUserPrivateKey = getCurrentUserPrivateKey(currentUser) ?: return null @@ -230,10 +213,29 @@ class ApiLocationService @Inject constructor( } val distributionMessage = SenderKeyDistributionMessage(decryptedDistribution) - val senderAddress = SignalProtocolAddress(spaceId, senderKeyDistribution.senderDeviceId) + val senderAddress = SignalProtocolAddress(spaceId, senderKeyData.senderDeviceId) bufferedSenderKeyStore.loadSenderKey(senderAddress, distributionMessage.distributionId) + // If the sender key data is outdated, we need to distribute the sender key to the pending users + if (senderKeyData.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 -> + senderKeyData.distributions.none { it.recipientId == member.user_id } + } + + if (membersPendingForSenderKey.isNotEmpty()) { + distributeSenderKeyToNewSpaceMembers( + spaceId = spaceId, + senderUserId = userId, + distributionMessage = distributionMessage, + senderDeviceId = senderKeyData.senderDeviceId, + apiSpaceMembers = membersPendingForSenderKey + ) + } + } + return try { GroupSessionBuilder(bufferedSenderKeyStore).process(senderAddress, distributionMessage) val groupCipher = GroupCipher(bufferedSenderKeyStore, senderAddress) @@ -261,47 +263,44 @@ class ApiLocationService @Inject constructor( } /** - * Distribute sender key to a new member. - */ - private suspend fun distributeSenderKeyToNewMember( + * 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, - newMember: ApiSpaceMember, - senderDeviceId: Int + distributionMessage: SenderKeyDistributionMessage, + senderDeviceId: Int, + apiSpaceMembers: List ) { - val groupAddress = SignalProtocolAddress(spaceId, senderDeviceId) - val sessionBuilder = GroupSessionBuilder(bufferedSenderKeyStore) - val distributionMessage = sessionBuilder.create(groupAddress, UUID.fromString(spaceId)) val distributionBytes = distributionMessage.serialize() + val docRef = spaceGroupKeysRef(spaceId) + val snapshot = docRef.get().await() + val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() - val publicBlob = newMember.identity_key_public ?: run { - Timber.e("New member's public key is null for userId=${newMember.user_id}") - return - } + val oldSenderKeyData = groupKeysDoc.senderKeys[senderUserId] ?: SenderKeyData() - val publicKey = try { - Curve.decodePoint(publicBlob.toBytes(), 0) - } catch (e: InvalidKeyException) { - Timber.e(e, "Invalid public key for new member userId=${newMember.user_id}") - return - } + val distributions = oldSenderKeyData.distributions.toMutableList() - val distribution = EphemeralECDHUtils.encrypt( - newMember.user_id, - distributionBytes, - publicKey - ) + for (member in apiSpaceMembers) { + val publicBlob = member.identity_key_public ?: continue + val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) - try { - spaceGroupKeysRef(spaceId).document(senderUserId) - .update("distributions", FieldValue.arrayUnion(distribution)) - .await() - Timber.d("Sender key distribution uploaded for new member: ${newMember.user_id} in spaceId=$spaceId.") - } catch (e: Exception) { - Timber.e( - e, - "Failed to upload sender key distribution for new member userId=${newMember.user_id} in spaceId=$spaceId" + // Encrypt distribution using member's public key + distributions.add( + EphemeralECDHUtils.encrypt( + member.user_id, + distributionBytes, + publicKey + ) ) } + + val newSenderKeyData = oldSenderKeyData.copy( + senderDeviceId = senderDeviceId, + distributions = distributions, + dataUpdatedAt = System.currentTimeMillis() + ) + docRef.update(mapOf("senderKeys.$senderUserId" to newSenderKeyData)).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 d478b1f5..789b2036 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 @@ -3,9 +3,10 @@ package com.canopas.yourspace.data.service.space import com.canopas.yourspace.data.models.space.ApiSpace 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.SPACE_MEMBER_ROLE_ADMIN import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_MEMBER -import com.canopas.yourspace.data.models.space.SenderKeyDistribution +import com.canopas.yourspace.data.models.space.SenderKeyData import com.canopas.yourspace.data.service.auth.AuthService import com.canopas.yourspace.data.service.place.ApiPlaceService import com.canopas.yourspace.data.service.user.ApiUserService @@ -40,10 +41,10 @@ class ApiSpaceService @Inject constructor( spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") .collection(FIRESTORE_COLLECTION_SPACE_MEMBERS) - private fun spaceGroupKeysRef(spaceId: String) = - spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null").collection( - FIRESTORE_COLLECTION_SPACE_GROUP_KEYS - ) + private fun spaceGroupKeysDoc(spaceId: String) = + spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") + .collection(FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) + .document(FIRESTORE_COLLECTION_SPACE_GROUP_KEYS) suspend fun createSpace(spaceName: String): String { val spaceId = UUID.randomUUID().toString() @@ -56,6 +57,11 @@ class ApiSpaceService @Inject constructor( admin_id = userId ) docRef.set(space).await() + + // Initialize the single group_keys doc to a default structure: + val emptyGroupKeys = GroupKeysDoc() + spaceGroupKeysDoc(spaceId).set(emptyGroupKeys).await() + joinSpace(spaceId, SPACE_MEMBER_ROLE_ADMIN) return spaceId } @@ -80,6 +86,10 @@ class ApiSpaceService @Inject constructor( apiUserService.addSpaceId(user.id, spaceId) + // Update the "docUpdatedAt" so others see membership changed + val docRef = spaceGroupKeysDoc(spaceId) + docRef.update("docUpdatedAt", System.currentTimeMillis()).await() + // Distribute sender key to all members distributeSenderKeyToSpaceMembers(spaceId, user.id) } @@ -107,17 +117,26 @@ class ApiSpaceService @Inject constructor( distributions.add(EphemeralECDHUtils.encrypt(member.user_id, distributionBytes, publicKey)) } - val docRef = spaceGroupKeysRef(spaceId).document(senderUserId) + val docRef = spaceGroupKeysDoc(spaceId) + val snapshot = docRef.get().await() + val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() - val data = SenderKeyDistribution( - senderId = senderUserId, + val oldSenderKeyData = groupKeysDoc.senderKeys[senderUserId] ?: SenderKeyData() + val newSenderKeyData = oldSenderKeyData.copy( senderDeviceId = deviceIdInt, distributions = distributions, - createdAt = System.currentTimeMillis() + dataUpdatedAt = System.currentTimeMillis() + ) + val updates = mapOf( + "senderKeys.$senderUserId" to newSenderKeyData.copy( + dataUpdatedAt = System.currentTimeMillis() + ), + "docUpdatedAt" to System.currentTimeMillis() ) - docRef.set(data).await() - Timber.d("Sender key distribution uploaded for $senderUserId in space $spaceId.") + docRef.update(updates).await() + + Timber.d("Sender key distribution updated for $senderUserId in space $spaceId") } suspend fun enableLocation(spaceId: String, userId: String, enable: Boolean) { 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 67b2fbea..427c722a 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 @@ -54,8 +54,8 @@ class BufferedSenderKeyStore @Inject constructor( distributionId: UUID, record: SenderKeyRecord ) { - val key = StoreKey(sender, distributionId) - if (inMemoryStore.containsKey(key)) { + val key = StoreKey(sender, distributionId, sender.deviceId) + if (inMemoryStore.any { it.key.address.deviceId == sender.deviceId && it.key.distributionId == distributionId }) { Timber.d("Sender key already exists for $sender and $distributionId") return } @@ -82,7 +82,7 @@ class BufferedSenderKeyStore @Inject constructor( } override fun loadSenderKey(sender: SignalProtocolAddress, distributionId: UUID): SenderKeyRecord? { - val key = StoreKey(sender, distributionId) + val key = StoreKey(sender, distributionId, sender.deviceId) return inMemoryStore[key] ?: runBlocking { senderKeyDao.getSenderKeyRecord( address = sender.name, @@ -99,15 +99,26 @@ class BufferedSenderKeyStore @Inject constructor( private suspend fun fetchSenderKeyFromServer(sender: SignalProtocolAddress): SenderKeyRecord? { val currentUser = userPreferences.currentUser ?: return null - return spaceSenderKeyRecordRef(sender.name.toString(), currentUser.id) - .document(sender.name.toString()).get().await().toObject(ApiSenderKeyRecord::class.java)?.let { - try { - SenderKeyRecord(it.record.toBytes()) - } catch (e: Exception) { - Timber.e(e, "Failed to deserialize sender key record") - null + return try { + spaceSenderKeyRecordRef(sender.name.toString(), currentUser.id) + .whereEqualTo("deviceId", sender.deviceId) + .get() + .await() + .documents + .firstOrNull() + ?.toObject(ApiSenderKeyRecord::class.java) + ?.let { apiSenderKeyRecord -> + try { + SenderKeyRecord(apiSenderKeyRecord.record.toBytes()) + } catch (e: Exception) { + Timber.e(e, "Failed to deserialize sender key record") + null + } } - } + } catch (e: Exception) { + Timber.e(e, "Failed to fetch sender key from server for sender: $sender") + null + } } override fun getSenderKeySharedWith(distributionId: DistributionId?): MutableSet { @@ -127,5 +138,5 @@ class BufferedSenderKeyStore @Inject constructor( } } - data class StoreKey(val address: SignalProtocolAddress, val distributionId: UUID) + data class StoreKey(val address: SignalProtocolAddress, val distributionId: UUID, val senderDeviceId: Int) } diff --git a/firestore.rules b/firestore.rules index 030a1eb9..90ee2bff 100644 --- a/firestore.rules +++ b/firestore.rules @@ -131,15 +131,9 @@ service cloud.firestore { allow write: if false; } - match /spaces/{spaceId}/group_keys/{senderId} { + match /spaces/{spaceId}/group_keys/group_keys { allow read: if isAuthorized() && isSpaceMember(spaceId); - allow create: if isAuthorized() && - request.auth.uid == senderId && - isSpaceMember(spaceId) && - request.resource.data.keys().hasAll(["senderId", "distributions", "createdAt"]) && - request.resource.data.senderId is string && - request.resource.data.distributions is list && - request.resource.data.createdAt is int; + allow create: if isAuthorized() && isSpaceAdmin(spaceId); allow update: if isAuthorized() && isSpaceMember(spaceId); allow delete: if isAuthorized() && isSpaceMember(spaceId); } From 557f29ecb87b1c098c8c5f1a6e83b1bb8e4fbed2 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 2 Jan 2025 15:59:18 +0530 Subject: [PATCH 13/30] final commit --- .../data/service/auth/AuthService.kt | 9 +- .../service/location/ApiJourneyService.kt | 5 +- .../service/location/ApiLocationService.kt | 99 ++++++++++--------- .../yourspace/data/storage/UserPreferences.kt | 18 ++++ .../BufferedSenderKeyStore.kt | 1 - 5 files changed, 81 insertions(+), 51 deletions(-) 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 56882e3b..9b6db9bf 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 @@ -9,7 +9,6 @@ import com.canopas.yourspace.data.storage.UserPreferences import com.google.android.gms.auth.api.signin.GoogleSignInAccount import com.google.firebase.auth.FirebaseAuth import com.google.firebase.auth.FirebaseUser -import com.google.firebase.firestore.Blob import javax.inject.Inject import javax.inject.Singleton @@ -102,13 +101,15 @@ class AuthService @Inject constructor( currentUser = user } - fun signOut() { + suspend fun signOut() { locationManager.stopLocationTracking() currentUser = null currentUserSession = null userPreferences.isFCMRegistered = false userPreferences.setOnboardShown(false) userPreferences.currentSpace = "" + userPreferences.clearPasskey() + userPreferences.clearPrivateKey() firebaseAuth.signOut() locationManager.stopService() locationCache.clear() @@ -123,6 +124,8 @@ class AuthService @Inject constructor( suspend fun generateAndSaveUserKeys(passKey: String) { currentUser?.let { val updatedUser = apiUserService.generateAndSaveUserKeys(currentUser!!, passKey) + updatedUser.identity_key_private?.toBytes() + ?.let { privateKey -> userPreferences.storePrivateKey(privateKey) } currentUser = updatedUser } } @@ -132,7 +135,7 @@ class AuthService @Inject constructor( val validationResult = apiUserService.validatePasskey(user, passKey) if (validationResult != null) { userPreferences.storePasskey(passKey) - currentUser = currentUser?.copy(identity_key_private = Blob.fromBytes(validationResult)) + userPreferences.storePrivateKey(validationResult) } return validationResult != null } 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 9ea15d2b..cb0cb0b5 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 @@ -89,12 +89,13 @@ 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() return try { - Curve.decodePrivatePoint(currentUser.identity_key_private?.toBytes()) + Curve.decodePrivatePoint(privateKey) } catch (e: InvalidKeyException) { Timber.e(e, "Error decoding private key for userId=${currentUser.id}") PrivateKeyUtils.decryptPrivateKey( - currentUser.identity_key_private?.toBytes() ?: return null, + privateKey ?: return null, currentUser.identity_key_salt?.toBytes() ?: return null, userPreferences.getPasskey() ?: return null )?.let { Curve.decodePrivatePoint(it) } 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 b085016b..49b16a24 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 @@ -19,14 +19,16 @@ import com.google.firebase.firestore.Blob import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.tasks.await import org.signal.libsignal.protocol.InvalidKeyException +import org.signal.libsignal.protocol.NoSessionException import org.signal.libsignal.protocol.SignalProtocolAddress import org.signal.libsignal.protocol.ecc.Curve import org.signal.libsignal.protocol.ecc.ECPrivateKey import org.signal.libsignal.protocol.groups.GroupCipher 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 @@ -102,60 +104,66 @@ class ApiLocationService @Inject constructor( longitude: Double, recordedAt: Long ) { - val cipherAndDistribution = getGroupCipherAndDistributionMessage( - spaceId = spaceId, - userId = userId, - canDistributeSenderKey = true - ) ?: return + try { + val cipherAndDistribution = getGroupCipherAndDistributionMessage( + spaceId = spaceId, + userId = userId, + canDistributeSenderKey = true + ) ?: return - val (distributionMessage, groupCipher) = cipherAndDistribution + val (distributionMessage, groupCipher) = cipherAndDistribution - val encryptedLatitude = groupCipher.encrypt( - distributionMessage.distributionId, - latitude.toString().toByteArray(Charsets.UTF_8) - ) - val encryptedLongitude = groupCipher.encrypt( - distributionMessage.distributionId, - longitude.toString().toByteArray(Charsets.UTF_8) - ) + val encryptedLatitude = groupCipher.encrypt( + distributionMessage.distributionId, + latitude.toString().toByteArray(Charsets.UTF_8) + ) + val encryptedLongitude = groupCipher.encrypt( + distributionMessage.distributionId, + longitude.toString().toByteArray(Charsets.UTF_8) + ) - val location = EncryptedApiLocation( - id = UUID.randomUUID().toString(), - user_id = userId, - encrypted_latitude = Blob.fromBytes(encryptedLatitude.serialize()), - encrypted_longitude = Blob.fromBytes(encryptedLongitude.serialize()), - created_at = recordedAt - ) + val location = EncryptedApiLocation( + id = UUID.randomUUID().toString(), + user_id = userId, + encrypted_latitude = Blob.fromBytes(encryptedLatitude.serialize()), + encrypted_longitude = Blob.fromBytes(encryptedLongitude.serialize()), + created_at = recordedAt + ) - try { spaceMemberLocationRef(spaceId, userId).document(location.id).set(location).await() } catch (e: Exception) { - Timber.e( - e, - "Failed to save encrypted location for userId: $userId in spaceId: $spaceId" - ) + when (e) { + is NoSessionException -> { + Timber.e("No session found for userId: $userId in spaceId: $spaceId. Skipping save.") + } + + is InvalidSenderKeySessionException -> { + Timber.e("Invalid sender key session for userId: $userId in spaceId: $spaceId. Skipping save.") + } + + else -> { + Timber.e(e, "Failed to save encrypted location for userId: $userId in spaceId: $spaceId") + } + } } } - fun getCurrentLocation(userId: String): Flow> = flow { - try { - val encryptedLocationsFlow = spaceMemberLocationRef(currentSpaceId, userId) - .whereEqualTo("user_id", userId) - .orderBy("created_at", Query.Direction.DESCENDING) - .limit(1) - .snapshotFlow(EncryptedApiLocation::class.java) - - encryptedLocationsFlow.collect { encryptedLocationList -> - emit( - encryptedLocationList.mapNotNull { encryptedLocation -> + fun getCurrentLocation(userId: String): Flow> = + spaceMemberLocationRef(currentSpaceId, userId) + .whereEqualTo("user_id", userId) + .orderBy("created_at", Query.Direction.DESCENDING) + .limit(1) + .snapshotFlow(EncryptedApiLocation::class.java) + .map { encryptedLocations -> + encryptedLocations.mapNotNull { encryptedLocation -> + try { decryptLocation(encryptedLocation, userId) + } catch (e: Exception) { + Timber.e(e, "Failed to decrypt location for userId: $userId") + null } - ) + } } - } catch (e: Exception) { - Timber.e(e, "Error while getting current location for userId: $userId") - } - } private suspend fun decryptLocation( encryptedLocation: EncryptedApiLocation, @@ -250,12 +258,13 @@ class ApiLocationService @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() return try { - Curve.decodePrivatePoint(currentUser.identity_key_private?.toBytes()) + Curve.decodePrivatePoint(privateKey) } catch (e: InvalidKeyException) { Timber.e(e, "Error decoding private key for userId=${currentUser.id}") PrivateKeyUtils.decryptPrivateKey( - currentUser.identity_key_private?.toBytes() ?: return null, + privateKey ?: return null, currentUser.identity_key_salt?.toBytes() ?: return null, userPreferences.getPasskey() ?: return null )?.let { Curve.decodePrivatePoint(it) } diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt b/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt index 931ad553..b2dcc8c1 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/UserPreferences.kt @@ -4,6 +4,7 @@ import android.util.Base64 import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.byteArrayPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.canopas.yourspace.data.models.user.ApiUser @@ -52,6 +53,7 @@ class UserPreferences @Inject constructor( val KEY_USER_MAP_STYLE = stringPreferencesKey("user_map_style") val KEY_USER_PASSKEY = stringPreferencesKey("user_passkey") + val KEY_USER_PRIVATE_KEY = byteArrayPreferencesKey("user_private_key") } suspend fun isIntroShown(): Boolean { @@ -188,6 +190,22 @@ class UserPreferences @Inject constructor( it.remove(KEY_USER_PASSKEY) } } + + suspend fun storePrivateKey(privateKey: ByteArray) { + preferencesDataStore.edit { preferences -> + preferences[PreferencesKey.KEY_USER_PRIVATE_KEY] = privateKey + } + } + + suspend fun getPrivateKey(): ByteArray? { + return preferencesDataStore.data.first()[PreferencesKey.KEY_USER_PRIVATE_KEY] + } + + suspend fun clearPrivateKey() { + preferencesDataStore.edit { + it.remove(PreferencesKey.KEY_USER_PRIVATE_KEY) + } + } } class BlobTypeAdapter { 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 427c722a..12a7b708 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 @@ -56,7 +56,6 @@ class BufferedSenderKeyStore @Inject constructor( ) { val key = StoreKey(sender, distributionId, sender.deviceId) if (inMemoryStore.any { it.key.address.deviceId == sender.deviceId && it.key.distributionId == distributionId }) { - Timber.d("Sender key already exists for $sender and $distributionId") return } inMemoryStore[key] = record From 755fb1e4bccbbe0240ff3e6715889ddabc643184 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 2 Jan 2025 17:10:22 +0530 Subject: [PATCH 14/30] existing spaces changes --- .../flow/home/map/component/SelectedUserDetail.kt | 2 +- .../ui/flow/pin/setpin/SetPinViewModel.kt | 15 +++++++++------ .../yourspace/data/repository/SpaceRepository.kt | 4 ++++ .../data/service/space/ApiSpaceService.kt | 10 ++++++++++ firestore.rules | 2 +- 5 files changed, 25 insertions(+), 8 deletions(-) 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 2ce30946..5d719955 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 ?: 0) + val time = timeAgo(location?.created_at ?: System.currentTimeMillis()) 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/pin/setpin/SetPinViewModel.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt index 35c41929..fbe0abc1 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 @@ -62,19 +62,22 @@ class SetPinViewModel @Inject constructor( if (pin.length == 4) { authService.generateAndSaveUserKeys(passKey = pin) val userId = authService.getUser()?.id - val userHasSpaces = userId?.let { - spaceRepository.getUserSpaces(it).firstOrNull()?.isNotEmpty() ?: false + val userSpaces = userId?.let { + spaceRepository.getUserSpaces(it) } - if (userHasSpaces == false) { + val userHasSpaces = userSpaces?.firstOrNull() != null + if (userHasSpaces) { + userPreferences.setOnboardShown(true) + spaceRepository.generateAndDistributeSenderKeysForExistingSpaces( + spaceIds = userSpaces?.firstOrNull()?.map { it.id } ?: emptyList()) navigator.navigateTo( - AppDestinations.onboard.path, + AppDestinations.home.path, popUpToRoute = AppDestinations.signIn.path, inclusive = true ) } else { - userPreferences.setOnboardShown(true) navigator.navigateTo( - AppDestinations.home.path, + AppDestinations.onboard.path, popUpToRoute = AppDestinations.signIn.path, inclusive = true ) 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 d2f31340..7512aa75 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 @@ -255,4 +255,8 @@ class SpaceRepository @Inject constructor( throw e } } + + suspend fun generateAndDistributeSenderKeysForExistingSpaces(spaceIds: List) { + spaceService.generateAndDistributeSenderKeysForExistingSpaces(spaceIds) + } } 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 789b2036..7309d719 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 @@ -198,4 +198,14 @@ class ApiSpaceService @Inject constructor( throw e } } + + + suspend fun generateAndDistributeSenderKeysForExistingSpaces(spaceIds: List) { + val userId = authService.currentUser?.id ?: return + spaceIds.forEach { spaceId -> + val emptyGroupKeys = GroupKeysDoc() + spaceGroupKeysDoc(spaceId).set(emptyGroupKeys).await() + distributeSenderKeyToSpaceMembers(spaceId,userId) + } + } } diff --git a/firestore.rules b/firestore.rules index 90ee2bff..6bb59d5b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -133,7 +133,7 @@ service cloud.firestore { match /spaces/{spaceId}/group_keys/group_keys { allow read: if isAuthorized() && isSpaceMember(spaceId); - allow create: if isAuthorized() && isSpaceAdmin(spaceId); + allow create: if isAuthorized() && (isSpaceAdmin(spaceId) || isSpaceMember(spaceId)); allow update: if isAuthorized() && isSpaceMember(spaceId); allow delete: if isAuthorized() && isSpaceMember(spaceId); } From 1d1f1a7725de4bc26b10554db6bcedc369888aad Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 2 Jan 2025 17:15:00 +0530 Subject: [PATCH 15/30] fix lint --- .../canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt | 3 ++- .../canopas/yourspace/data/service/space/ApiSpaceService.kt | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 fbe0abc1..e755b804 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 @@ -69,7 +69,8 @@ class SetPinViewModel @Inject constructor( if (userHasSpaces) { userPreferences.setOnboardShown(true) spaceRepository.generateAndDistributeSenderKeysForExistingSpaces( - spaceIds = userSpaces?.firstOrNull()?.map { it.id } ?: emptyList()) + spaceIds = userSpaces?.firstOrNull()?.map { it.id } ?: emptyList() + ) navigator.navigateTo( AppDestinations.home.path, popUpToRoute = AppDestinations.signIn.path, 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 7309d719..6b7646af 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 @@ -199,13 +199,12 @@ class ApiSpaceService @Inject constructor( } } - suspend fun generateAndDistributeSenderKeysForExistingSpaces(spaceIds: List) { val userId = authService.currentUser?.id ?: return spaceIds.forEach { spaceId -> val emptyGroupKeys = GroupKeysDoc() spaceGroupKeysDoc(spaceId).set(emptyGroupKeys).await() - distributeSenderKeyToSpaceMembers(spaceId,userId) + distributeSenderKeyToSpaceMembers(spaceId, userId) } } } From e8eaecbd6d1b839cf6b6136f90845fe74e4dd8ea Mon Sep 17 00:00:00 2001 From: cp-megh Date: Fri, 3 Jan 2025 10:59:26 +0530 Subject: [PATCH 16/30] fix session error and existing users flow --- .../canopas/yourspace/ui/flow/pin/setpin/SetPinViewModel.kt | 2 +- .../yourspace/data/service/location/ApiJourneyService.kt | 5 ++--- .../data/storage/bufferedkeystore/BufferedSenderKeyStore.kt | 3 ++- 3 files changed, 5 insertions(+), 5 deletions(-) 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 e755b804..cf0bab40 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 @@ -65,7 +65,7 @@ class SetPinViewModel @Inject constructor( val userSpaces = userId?.let { spaceRepository.getUserSpaces(it) } - val userHasSpaces = userSpaces?.firstOrNull() != null + val userHasSpaces = userSpaces?.firstOrNull() != null && userSpaces.firstOrNull()?.isNotEmpty() == true if (userHasSpaces) { userPreferences.setOnboardShown(true) spaceRepository.generateAndDistributeSenderKeysForExistingSpaces( 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 cb0cb0b5..45c7d331 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 @@ -76,13 +76,12 @@ class ApiJourneyService @Inject constructor( // Initialize the session try { GroupSessionBuilder(bufferedSenderKeyStore).process(groupAddress, distributionMessage) + val groupCipher = GroupCipher(bufferedSenderKeyStore, groupAddress) + return Pair(distributionMessage, groupCipher) } catch (e: Exception) { Timber.e(e, "Error processing group session for spaceId=$spaceId, userId=$userId") return null } - - val groupCipher = GroupCipher(bufferedSenderKeyStore, groupAddress) - return Pair(distributionMessage, groupCipher) } /** 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 12a7b708..ed8cca84 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 @@ -45,8 +45,9 @@ class BufferedSenderKeyStore @Inject constructor( private suspend fun saveSenderKeyToServer(senderKeyRecord: ApiSenderKeyRecord) { val currentUser = userPreferences.currentUser ?: return + val uniqueDocId = "${senderKeyRecord.deviceId}-${senderKeyRecord.distributionId}" spaceSenderKeyRecordRef(senderKeyRecord.distributionId, currentUser.id) - .document(senderKeyRecord.distributionId).set(senderKeyRecord).await() + .document(uniqueDocId).set(senderKeyRecord).await() } override fun storeSenderKey( From 24bb9de5e8b0f79e2894c8f3b2674e833e3e9dc3 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Fri, 3 Jan 2025 14:50:58 +0530 Subject: [PATCH 17/30] PR changes --- app/build.gradle.kts | 2 +- .../ui/flow/pin/enterpin/EnterPinScreen.kt | 22 ++-- .../ui/flow/pin/enterpin/EnterPinViewModel.kt | 26 ++-- .../ui/flow/pin/setpin/SetPinScreen.kt | 30 +++-- .../ui/flow/pin/setpin/SetPinViewModel.kt | 97 +++++++++------ app/src/main/res/values/strings.xml | 6 +- data/build.gradle.kts | 12 +- .../yourspace/data/di/AppDataProvider.kt | 3 +- .../data/models/location/ApiLocation.kt | 4 +- .../data/models/location/LocationJourney.kt | 8 +- .../yourspace/data/models/space/ApiSpace.kt | 24 +++- .../data/repository/SpaceRepository.kt | 7 +- .../data/service/auth/AuthService.kt | 38 +++--- .../service/location/ApiJourneyService.kt | 43 +++---- .../service/location/ApiLocationService.kt | 112 +++++++++++------- .../data/service/space/ApiSpaceService.kt | 64 +++++----- .../BufferedSenderKeyStore.kt | 24 ++-- .../data/storage/database/AppDatabase.kt | 8 ++ .../data/storage/database/SenderKeyDao.kt | 2 + .../data/utils/EphemeralECDHUtils.kt | 4 +- .../yourspace/data/utils/PrivateKeyUtils.kt | 2 +- firestore.rules | 11 +- 22 files changed, 345 insertions(+), 204 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c84d7bb..76bdd23c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -207,7 +207,7 @@ dependencies { implementation("androidx.core:core-splashscreen:1.0.1") // Desugaring - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") // Gson implementation("com.google.code.gson:gson:2.10.1") 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 b1c3c502..7835c201 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 @@ -14,10 +14,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -29,6 +28,7 @@ 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 @@ -53,8 +53,14 @@ private fun EnterPinContent(modifier: Modifier) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() val context = LocalContext.current - val invalidPinText by remember { - mutableStateOf(context.getString(R.string.enter_pin_error_text)) + var pinErrorText = "" + LaunchedEffect(state.pinError) { + 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 -> "" + } } Column( @@ -91,8 +97,8 @@ private fun EnterPinContent(modifier: Modifier) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = state.pinError ?: "", - color = if (!state.pinError.isNullOrEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, + text = pinErrorText, + color = if (pinErrorText.isNotEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) ) @@ -102,9 +108,9 @@ private fun EnterPinContent(modifier: Modifier) { PrimaryButton( label = stringResource(R.string.enter_pin_continue_button_text), onClick = { - viewModel.processPin(invalidPinText) + viewModel.processPin() }, - enabled = state.pin != "" && state.pinError == "", + enabled = state.pin != "" && state.pinError == null, 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 a08521dd..6fbe2f5d 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,6 +6,7 @@ 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 @@ -32,7 +33,18 @@ class EnterPinViewModel @Inject constructor( fun onPinChanged(newPin: String) { _state.value = _state.value.copy(pin = newPin) - _state.value = _state.value.copy(pinError = if (newPin.length == 4) "" else null) + 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) + } else if (pin.length == 4 && !pin.contains(Regex("[0-9]"))) { + _state.value = _state.value.copy(pinError = PinErrorState.CHARACTERS_ERROR) + } } fun checkInternetConnection() { @@ -47,10 +59,11 @@ class EnterPinViewModel @Inject constructor( } } - fun processPin(invalidPinText: String) = viewModelScope.launch(appDispatcher.MAIN) { + fun processPin() = viewModelScope.launch(appDispatcher.MAIN) { _state.value = _state.value.copy(showLoader = true) val pin = state.value.pin - if (pin.length == 4) { + validatePin() + if (state.value.pinError == null) { val isPinValid = authService.validatePasskey(passKey = pin) if (isPinValid) { userPreferences.setOnboardShown(true) @@ -60,7 +73,7 @@ class EnterPinViewModel @Inject constructor( inclusive = true ) } else { - _state.value = _state.value.copy(pinError = invalidPinText) + _state.value = _state.value.copy(pinError = PinErrorState.INVALID_PIN) } } } @@ -69,7 +82,6 @@ class EnterPinViewModel @Inject constructor( data class EnterPinScreenState( val showLoader: Boolean = false, val pin: String = "", - val pinError: String? = null, - val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available, - val error: Exception? = null + val pinError: PinErrorState? = null, + 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 6525c28b..8d2575cf 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 @@ -14,10 +14,9 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -27,6 +26,7 @@ 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.AppBanner import com.canopas.yourspace.ui.component.OtpInputField import com.canopas.yourspace.ui.component.PrimaryButton @@ -53,8 +53,14 @@ private fun SetPinContent(modifier: Modifier) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() val context = LocalContext.current - val lengthErrorText by remember { - mutableStateOf(context.getString(R.string.set_pin_error_text_length)) + var pinErrorText = "" + + LaunchedEffect(state.pinError) { + 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 -> "" + } } Column( @@ -91,8 +97,8 @@ private fun SetPinContent(modifier: Modifier) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = state.pinError ?: "", - color = if (!state.pinError.isNullOrEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, + text = pinErrorText, + color = if (pinErrorText.isNotEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp) ) @@ -102,11 +108,19 @@ private fun SetPinContent(modifier: Modifier) { PrimaryButton( label = stringResource(R.string.set_pin_button_text), onClick = { - viewModel.processPin(lengthErrorText) + viewModel.processPin() }, - enabled = state.pin != "" && state.pinError == "", + enabled = state.pin != "" && state.pinError == null, modifier = Modifier.fillMaxWidth(), showLoader = state.showLoader ) } + + if (state.error != null) { + AppBanner( + msg = stringResource(R.string.common_something_went_wrong_error) + ) { + viewModel.onPinChanged("") + } + } } 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 cf0bab40..e632fa64 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,6 +17,12 @@ 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, @@ -36,7 +42,18 @@ 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 = "") + _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) + } else if (pin.length == 4 && !pin.all { it.isDigit() }) { + _state.value = _state.value.copy(pinError = PinErrorState.CHARACTERS_ERROR) + } else { + _state.value = _state.value.copy(pinError = null) } } @@ -52,39 +69,52 @@ class SetPinViewModel @Inject constructor( } } - fun processPin(lengthError: String) = viewModelScope.launch(appDispatcher.MAIN) { + fun processPin() = viewModelScope.launch(appDispatcher.MAIN) { _state.value = _state.value.copy(showLoader = true) val pin = state.value.pin - if (pin.length < 4) { - _state.value = _state.value.copy(pinError = lengthError) - return@launch - } - if (pin.length == 4) { - authService.generateAndSaveUserKeys(passKey = pin) - val userId = authService.getUser()?.id - val userSpaces = userId?.let { - spaceRepository.getUserSpaces(it) - } - val userHasSpaces = userSpaces?.firstOrNull() != null && userSpaces.firstOrNull()?.isNotEmpty() == true - if (userHasSpaces) { - userPreferences.setOnboardShown(true) - spaceRepository.generateAndDistributeSenderKeysForExistingSpaces( - spaceIds = userSpaces?.firstOrNull()?.map { it.id } ?: emptyList() - ) - navigator.navigateTo( - AppDestinations.home.path, - popUpToRoute = AppDestinations.signIn.path, - inclusive = true - ) - } else { - navigator.navigateTo( - AppDestinations.onboard.path, - popUpToRoute = AppDestinations.signIn.path, - inclusive = true - ) + 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 + } + + val userSpaces = spaceRepository.getUserSpaces(userId) + + 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 + } + + navigator.navigateTo( + AppDestinations.home.path, + popUpToRoute = AppDestinations.signIn.path, + inclusive = true + ) + } else { + navigator.navigateTo( + AppDestinations.onboard.path, + popUpToRoute = AppDestinations.signIn.path, + inclusive = true + ) + } + } catch (e: Exception) { + _state.value = _state.value.copy(error = e) } - } else { - _state.value = _state.value.copy(pinError = "Pin must be 4 characters") } } } @@ -92,8 +122,7 @@ class SetPinViewModel @Inject constructor( data class EnterPinScreenState( val showLoader: Boolean = false, val pin: String = "", - val confirmPin: String = "", - val pinError: String? = null, + val pinError: PinErrorState? = null, val connectivityStatus: ConnectivityObserver.Status = ConnectivityObserver.Status.Available, - val error: Exception? = null + val error: Throwable? = null ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 683f00f9..d5b80223 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -36,6 +36,7 @@ Go to Settings No Group Found! The group you are looking for isn\'t available or you may have been removed. + Something went wrong! In-App messaging It is used to show messages @@ -313,11 +314,14 @@ 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 + 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 56c50496..8ef13102 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -14,6 +14,11 @@ android { minSdk = 23 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + javaCompileOptions { + annotationProcessorOptions { + arguments["room.schemaLocation"] = "$projectDir/schemas" + } + } consumerProguardFiles("consumer-rules.pro") } @@ -81,7 +86,7 @@ dependencies { implementation("androidx.room:room-ktx:2.6.1") // Desugaring - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.6") + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") // Place implementation("com.google.android.libraries.places:places:4.0.0") @@ -89,9 +94,4 @@ dependencies { // Signal Protocol implementation("org.signal:libsignal-client:0.64.1") implementation("org.signal:libsignal-android:0.64.1") - - // Bouncy-castle for Signal Protocol - implementation("org.bouncycastle:bcprov-jdk15on:1.70") - - implementation("androidx.security:security-crypto:1.1.0-alpha03") } diff --git a/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt b/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt index 437d29a5..69c2605b 100644 --- a/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt +++ b/data/src/main/java/com/canopas/yourspace/data/di/AppDataProvider.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.room.Room import com.canopas.yourspace.data.models.user.ApiUserSession import com.canopas.yourspace.data.storage.database.AppDatabase +import com.canopas.yourspace.data.storage.database.MIGRATION_1_1 import com.canopas.yourspace.data.storage.database.SenderKeyDao import com.canopas.yourspace.data.utils.Config import com.google.android.gms.location.GeofencingClient @@ -68,7 +69,7 @@ class AppDataProvider { AppDatabase::class.java, DATABASE_NAME ) - .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_1_1) .build() } diff --git a/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt b/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt index e0bfc4af..78b7c0c0 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt @@ -12,7 +12,7 @@ data class ApiLocation( val user_id: String = "", val latitude: Double = 0.0, val longitude: Double = 0.0, - val created_at: Long? = System.currentTimeMillis() + val created_at: Long = System.currentTimeMillis() ) @Keep @@ -22,7 +22,7 @@ data class EncryptedApiLocation( val user_id: String = "", val encrypted_latitude: Blob = Blob.fromBytes(ByteArray(0)), // Base64 encoded encrypted latitude val encrypted_longitude: Blob = Blob.fromBytes(ByteArray(0)), // Base64 encoded encrypted longitude - val created_at: Long? = System.currentTimeMillis() + val created_at: Long = System.currentTimeMillis() ) fun ApiLocation.toLocation() = android.location.Location("").apply { 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 fa465980..4fca5909 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 @@ -19,8 +19,8 @@ data class LocationJourney( val route_distance: Double? = null, val route_duration: Long? = null, val routes: List = emptyList(), - val created_at: Long? = System.currentTimeMillis(), - val update_at: Long? = System.currentTimeMillis() + val created_at: Long = System.currentTimeMillis(), + val update_at: Long = System.currentTimeMillis() ) @Keep @@ -35,8 +35,8 @@ data class EncryptedLocationJourney( val route_distance: Double? = null, val route_duration: Long? = null, val encrypted_routes: List = emptyList(), // Encrypted journey routes - val created_at: Long? = System.currentTimeMillis(), - val updated_at: Long? = System.currentTimeMillis() + val created_at: Long = System.currentTimeMillis(), + val updated_at: Long = System.currentTimeMillis() ) @Keep 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 445f3e77..be2ee9c1 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 @@ -54,7 +54,7 @@ data class ApiSpaceInvitation( @Keep data class GroupKeysDoc( val docUpdatedAt: Long = System.currentTimeMillis(), // To be updated whenever users are added/removed - val senderKeys: Map = emptyMap() + val memberKeys: Map = emptyMap() ) /* @@ -62,8 +62,8 @@ data class GroupKeysDoc( * in Firestore for a single sender's key distribution. */ @Keep -data class SenderKeyData( - val senderDeviceId: Int = 0, +data class MemberKeyData( + val memberDeviceId: Int = 0, val distributions: List = emptyList(), val dataUpdatedAt: Long = System.currentTimeMillis() // To be updated whenever a new distribution is added ) @@ -78,4 +78,20 @@ data class EncryptedDistribution( 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 -) +) { + 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(iv.toBytes().size == 16 || iv.toBytes().isEmpty()) { + "Invalid size for iv: expected 16 bytes, got ${iv.toBytes().size} bytes." + } + require(ciphertext.toBytes().size <= 64 * 1024 || ciphertext.toBytes().isEmpty()) { + "Invalid size for ciphertext: maximum allowed size is 64 KB, got ${ciphertext.toBytes().size} bytes." + } + } +} 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 7512aa75..76f26b7f 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 @@ -257,6 +257,11 @@ class SpaceRepository @Inject constructor( } suspend fun generateAndDistributeSenderKeysForExistingSpaces(spaceIds: List) { - spaceService.generateAndDistributeSenderKeysForExistingSpaces(spaceIds) + try { + 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 9b6db9bf..47c442e0 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 @@ -102,17 +102,24 @@ class AuthService @Inject constructor( } suspend fun signOut() { - locationManager.stopLocationTracking() - currentUser = null - currentUserSession = null - userPreferences.isFCMRegistered = false - userPreferences.setOnboardShown(false) - userPreferences.currentSpace = "" - userPreferences.clearPasskey() - userPreferences.clearPrivateKey() - firebaseAuth.signOut() - locationManager.stopService() - locationCache.clear() + try { + locationManager.stopLocationTracking() + currentUser = null + currentUserSession = null + userPreferences.isFCMRegistered = false + userPreferences.setOnboardShown(false) + userPreferences.currentSpace = "" + userPreferences.clearPasskey() + userPreferences.clearPrivateKey() + firebaseAuth.signOut() + locationManager.stopService() + locationCache.clear() + } catch (e: Exception) { + throw SecurityException("Failed to completely sign out. Some sensitive data might not be cleared.") + } finally { + // Force garbage collection to clean up any sensitive data in memory + System.gc() + } } suspend fun deleteAccount() { @@ -122,11 +129,12 @@ class AuthService @Inject constructor( } suspend fun generateAndSaveUserKeys(passKey: String) { - currentUser?.let { - val updatedUser = apiUserService.generateAndSaveUserKeys(currentUser!!, passKey) - updatedUser.identity_key_private?.toBytes() - ?.let { privateKey -> userPreferences.storePrivateKey(privateKey) } + val user = currentUser ?: throw IllegalStateException("No user logged in") + try { + val updatedUser = apiUserService.generateAndSaveUserKeys(user, passKey) currentUser = updatedUser + } catch (e: Exception) { + throw SecurityException("Failed to generate user keys", 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 45c7d331..b4b56e70 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 @@ -38,6 +38,8 @@ class ApiJourneyService @Inject constructor( private val bufferedSenderKeyStore: BufferedSenderKeyStore ) { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) + private val currentSpaceId: String + get() = userPreferences.currentSpace ?: "" private fun spaceMemberRef(spaceId: String) = spaceRef.document(spaceId.takeIf { it.isNotBlank() } ?: "null") @@ -58,19 +60,19 @@ class ApiJourneyService @Inject constructor( ): Pair? { val snapshot = spaceGroupKeysRef(spaceId).get().await() val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: return null - val senderKeyData = groupKeysDoc.senderKeys[userId] ?: return null + val memberKeyData = groupKeysDoc.memberKeys[userId] ?: return null val currentUser = userPreferences.currentUser ?: return null val privateKey = getCurrentUserPrivateKey(currentUser) ?: return null val distribution = - senderKeyData.distributions.firstOrNull { it.recipientId == currentUser.id } + memberKeyData.distributions.firstOrNull { it.recipientId == currentUser.id } ?: return null val decryptedDistributionBytes = EphemeralECDHUtils.decrypt(distribution, privateKey) ?: return null val distributionMessage = SenderKeyDistributionMessage(decryptedDistributionBytes) - val groupAddress = SignalProtocolAddress(spaceId, senderKeyData.senderDeviceId) + val groupAddress = SignalProtocolAddress(spaceId, memberKeyData.memberDeviceId) bufferedSenderKeyStore.loadSenderKey(groupAddress, distributionMessage.distributionId) // Initialize the session @@ -84,6 +86,21 @@ class ApiJourneyService @Inject constructor( } } + private suspend fun withGroupCipher( + userId: String, + block: suspend (GroupCipher) -> T?, + defaultValue: T + ): T { + val (_, groupCipher) = getGroupCipherAndDistributionMessage(currentSpaceId, userId) + ?: return defaultValue + return try { + block(groupCipher) ?: defaultValue + } catch (e: Exception) { + Timber.e(e, "Error executing operation for userId: $userId") + defaultValue + } + } + /** * Decrypts and retrieves the current user's private key. */ @@ -254,15 +271,8 @@ class ApiJourneyService @Inject constructor( } } - suspend fun getLastJourneyLocation(userId: String): LocationJourney? { - val currentSpaceId = userPreferences.currentSpace ?: return null - val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, userId) ?: run { - Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: $userId") - return null - } - val (_, groupCipher) = cipherAndMessage - - return try { + suspend fun getLastJourneyLocation(userId: String): LocationJourney? = + withGroupCipher(userId, { groupCipher -> spaceMemberJourneyRef(currentSpaceId, userId) .whereEqualTo("user_id", userId) .orderBy("created_at", Query.Direction.DESCENDING) @@ -273,14 +283,9 @@ class ApiJourneyService @Inject constructor( .firstOrNull() ?.toObject() ?.toDecryptedLocationJourney(groupCipher) - } catch (e: Exception) { - Timber.e(e, "Error while getting last location journey for userId: $userId") - null - } - } + }, null) suspend fun getMoreJourneyHistory(userId: String, from: Long?): List { - val currentSpaceId = userPreferences.currentSpace ?: return emptyList() val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, userId) ?: run { Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: $userId") return emptyList() @@ -311,7 +316,6 @@ class ApiJourneyService @Inject constructor( } suspend fun getJourneyHistory(userId: String, from: Long, to: Long): List { - val currentSpaceId = userPreferences.currentSpace ?: return emptyList() val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, userId) ?: run { Timber.e("Failed to retrieve GroupCipher and DistributionMessage for spaceId: $currentSpaceId, userId: $userId") return emptyList() @@ -352,7 +356,6 @@ class ApiJourneyService @Inject constructor( } suspend fun getLocationJourneyFromId(journeyId: String): LocationJourney? { - val currentSpaceId = userPreferences.currentSpace ?: return null val currentUser = userPreferences.currentUser ?: return null val cipherAndMessage = getGroupCipherAndDistributionMessage(currentSpaceId, currentUser.id) ?: run { 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 49b16a24..ffda51f8 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 @@ -4,7 +4,7 @@ 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.GroupKeysDoc -import com.canopas.yourspace.data.models.space.SenderKeyData +import com.canopas.yourspace.data.models.space.MemberKeyData import com.canopas.yourspace.data.models.user.ApiUser import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore @@ -31,18 +31,21 @@ 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 @Singleton class ApiLocationService @Inject constructor( - db: FirebaseFirestore, + private val db: FirebaseFirestore, private val locationManager: LocationManager, private val userPreferences: UserPreferences, private val bufferedSenderKeyStore: BufferedSenderKeyStore ) { - var currentSpaceId: String = userPreferences.currentSpace ?: "" + var currentSpaceId: String + get() = userPreferences.currentSpace ?: "" + set(value) { + userPreferences.currentSpace = value + } private val spaceRef by lazy { db.collection(FIRESTORE_COLLECTION_SPACES) } @@ -123,7 +126,6 @@ class ApiLocationService @Inject constructor( ) val location = EncryptedApiLocation( - id = UUID.randomUUID().toString(), user_id = userId, encrypted_latitude = Blob.fromBytes(encryptedLatitude.serialize()), encrypted_longitude = Blob.fromBytes(encryptedLongitude.serialize()), @@ -142,7 +144,10 @@ class ApiLocationService @Inject constructor( } else -> { - Timber.e(e, "Failed to save encrypted location for userId: $userId in spaceId: $spaceId") + Timber.e( + e, + "Failed to save encrypted location for userId: $userId in spaceId: $spaceId" + ) } } } @@ -177,11 +182,18 @@ class ApiLocationService @Inject constructor( val longitudeBytes = groupCipher.decrypt(encryptedLocation.encrypted_longitude.toBytes()) + 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 + } + ApiLocation( id = encryptedLocation.id, user_id = userId, - latitude = latitudeBytes.toString(Charsets.UTF_8).toDouble(), - longitude = longitudeBytes.toString(Charsets.UTF_8).toDouble(), + latitude = latitude, + longitude = longitude, created_at = encryptedLocation.created_at ) } catch (e: Exception) { @@ -206,9 +218,9 @@ class ApiLocationService @Inject constructor( val snapshot = spaceGroupKeysRef(spaceId).get().await() val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: return null - val senderKeyData = groupKeysDoc.senderKeys[userId] ?: return null + val memberKeyData = groupKeysDoc.memberKeys[userId] ?: return null - val distribution = senderKeyData.distributions.firstOrNull { + val distribution = memberKeyData.distributions.firstOrNull { it.recipientId == currentUser.id } ?: return null @@ -221,16 +233,16 @@ class ApiLocationService @Inject constructor( } val distributionMessage = SenderKeyDistributionMessage(decryptedDistribution) - val senderAddress = SignalProtocolAddress(spaceId, senderKeyData.senderDeviceId) + val groupAddress = SignalProtocolAddress(spaceId, memberKeyData.memberDeviceId) - bufferedSenderKeyStore.loadSenderKey(senderAddress, distributionMessage.distributionId) + bufferedSenderKeyStore.loadSenderKey(groupAddress, distributionMessage.distributionId) // If the sender key data is outdated, we need to distribute the sender key to the pending users - if (senderKeyData.dataUpdatedAt < groupKeysDoc.docUpdatedAt && canDistributeSenderKey) { + 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 -> - senderKeyData.distributions.none { it.recipientId == member.user_id } + memberKeyData.distributions.none { it.recipientId == member.user_id } } if (membersPendingForSenderKey.isNotEmpty()) { @@ -238,15 +250,15 @@ class ApiLocationService @Inject constructor( spaceId = spaceId, senderUserId = userId, distributionMessage = distributionMessage, - senderDeviceId = senderKeyData.senderDeviceId, + senderDeviceId = memberKeyData.memberDeviceId, apiSpaceMembers = membersPendingForSenderKey ) } } return try { - GroupSessionBuilder(bufferedSenderKeyStore).process(senderAddress, distributionMessage) - val groupCipher = GroupCipher(bufferedSenderKeyStore, senderAddress) + GroupSessionBuilder(bufferedSenderKeyStore).process(groupAddress, distributionMessage) + val groupCipher = GroupCipher(bufferedSenderKeyStore, groupAddress) Pair(distributionMessage, groupCipher) } catch (e: Exception) { Timber.e(e, "Error processing group session for spaceId=$spaceId, userId=$userId") @@ -258,7 +270,12 @@ class ApiLocationService @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 = try { + userPreferences.getPrivateKey() ?: currentUser.identity_key_private?.toBytes() + } catch (e: Exception) { + Timber.e(e, "Failed to retrieve private key for user ${currentUser.id}") + return null + } return try { Curve.decodePrivatePoint(privateKey) } catch (e: InvalidKeyException) { @@ -282,34 +299,39 @@ class ApiLocationService @Inject constructor( senderDeviceId: Int, apiSpaceMembers: List ) { - val distributionBytes = distributionMessage.serialize() - val docRef = spaceGroupKeysRef(spaceId) - val snapshot = docRef.get().await() - val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() - - val oldSenderKeyData = groupKeysDoc.senderKeys[senderUserId] ?: SenderKeyData() - - val distributions = oldSenderKeyData.distributions.toMutableList() - - for (member in apiSpaceMembers) { - val publicBlob = member.identity_key_public ?: continue - val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) - - // Encrypt distribution using member's public key - distributions.add( - EphemeralECDHUtils.encrypt( - member.user_id, - distributionBytes, - 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 newSenderKeyData = oldSenderKeyData.copy( - senderDeviceId = senderDeviceId, - distributions = distributions, - dataUpdatedAt = System.currentTimeMillis() - ) - docRef.update(mapOf("senderKeys.$senderUserId" to newSenderKeyData)).await() + val newMemberKeyData = oldMemberKeyData.copy( + memberDeviceId = senderDeviceId, + distributions = distributions, + dataUpdatedAt = System.currentTimeMillis() + ) + transaction.update(docRef, mapOf("memberKeys.$senderUserId" to newMemberKeyData)) + }.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 6b7646af..1323f63e 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 @@ -4,13 +4,12 @@ import com.canopas.yourspace.data.models.space.ApiSpace 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.space.SPACE_MEMBER_ROLE_ADMIN import com.canopas.yourspace.data.models.space.SPACE_MEMBER_ROLE_MEMBER -import com.canopas.yourspace.data.models.space.SenderKeyData import com.canopas.yourspace.data.service.auth.AuthService import com.canopas.yourspace.data.service.place.ApiPlaceService import com.canopas.yourspace.data.service.user.ApiUserService -import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.storage.bufferedkeystore.BufferedSenderKeyStore import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_GROUP_KEYS @@ -33,7 +32,6 @@ class ApiSpaceService @Inject constructor( private val authService: AuthService, private val apiUserService: ApiUserService, private val placeService: ApiPlaceService, - private val userPreferences: UserPreferences, private val bufferedSenderKeyStore: BufferedSenderKeyStore ) { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) @@ -99,9 +97,8 @@ class ApiSpaceService @Inject constructor( * for each member using their public key(ECDH). **/ private suspend fun distributeSenderKeyToSpaceMembers(spaceId: String, senderUserId: String) { - val deviceId = userPreferences.currentUserSession?.device_id ?: "" - val deviceIdInt = deviceId.hashCode() and 0x7FFFFFFF - val groupAddress = SignalProtocolAddress(spaceId, deviceIdInt) + 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 distributionBytes = distributionMessage.serialize() @@ -111,31 +108,38 @@ class ApiSpaceService @Inject constructor( for (member in apiSpaceMembers) { val publicBlob = member.identity_key_public ?: continue - val publicKey = Curve.decodePoint(publicBlob.toBytes(), 0) + 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}") + continue + } + Curve.decodePoint(publicKeyBytes, 0) + } catch (e: Exception) { + Timber.e(e, "Failed to decode public key for member ${member.user_id}") + continue + } // Encrypt distribution using member's public key distributions.add(EphemeralECDHUtils.encrypt(member.user_id, distributionBytes, publicKey)) } - val docRef = spaceGroupKeysDoc(spaceId) - val snapshot = docRef.get().await() - val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() - - val oldSenderKeyData = groupKeysDoc.senderKeys[senderUserId] ?: SenderKeyData() - val newSenderKeyData = oldSenderKeyData.copy( - senderDeviceId = deviceIdInt, - distributions = distributions, - dataUpdatedAt = System.currentTimeMillis() - ) - val updates = mapOf( - "senderKeys.$senderUserId" to newSenderKeyData.copy( + db.runTransaction { transaction -> + val docRef = spaceGroupKeysDoc(spaceId) + val snapshot = transaction.get(docRef) + val groupKeysDoc = snapshot.toObject(GroupKeysDoc::class.java) ?: GroupKeysDoc() + val oldMemberKeyData = groupKeysDoc.memberKeys[senderUserId] ?: MemberKeyData() + val newMemberKeyData = oldMemberKeyData.copy( + memberDeviceId = deviceId.toInt(), + distributions = distributions, dataUpdatedAt = System.currentTimeMillis() - ), - "docUpdatedAt" to System.currentTimeMillis() - ) - - docRef.update(updates).await() - + ) + val updates = mapOf( + "memberKeys.$senderUserId" to newMemberKeyData, + "docUpdatedAt" to System.currentTimeMillis() + ) + transaction.update(docRef, updates) + }.await() Timber.d("Sender key distribution updated for $senderUserId in space $spaceId") } @@ -202,9 +206,13 @@ class ApiSpaceService @Inject constructor( suspend fun generateAndDistributeSenderKeysForExistingSpaces(spaceIds: List) { val userId = authService.currentUser?.id ?: return spaceIds.forEach { spaceId -> - val emptyGroupKeys = GroupKeysDoc() - spaceGroupKeysDoc(spaceId).set(emptyGroupKeys).await() - distributeSenderKeyToSpaceMembers(spaceId, userId) + try { + val emptyGroupKeys = GroupKeysDoc() + spaceGroupKeysDoc(spaceId).set(emptyGroupKeys).await() + distributeSenderKeyToSpaceMembers(spaceId, userId) + } catch (e: Exception) { + Timber.e(e, "Failed to distribute sender key for space $spaceId") + } } } } 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 ed8cca84..a6486676 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 @@ -4,11 +4,14 @@ import com.canopas.yourspace.data.models.user.ApiSenderKeyRecord import com.canopas.yourspace.data.storage.UserPreferences import com.canopas.yourspace.data.storage.database.SenderKeyDao import com.canopas.yourspace.data.storage.database.SenderKeyEntity +import com.canopas.yourspace.data.utils.AppDispatcher import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACES import com.canopas.yourspace.data.utils.Config.FIRESTORE_COLLECTION_SPACE_MEMBERS 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.CoroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.tasks.await import org.signal.libsignal.protocol.SignalProtocolAddress @@ -26,7 +29,8 @@ import javax.inject.Singleton class BufferedSenderKeyStore @Inject constructor( db: FirebaseFirestore, @Named("sender_key_dao") private val senderKeyDao: SenderKeyDao, - private val userPreferences: UserPreferences + private val userPreferences: UserPreferences, + private val appDispatcher: AppDispatcher ) : SignalServiceSenderKeyStore { private val spaceRef = db.collection(FIRESTORE_COLLECTION_SPACES) @@ -44,10 +48,14 @@ class BufferedSenderKeyStore @Inject constructor( private val sharedWithAddresses: MutableSet = mutableSetOf() private suspend fun saveSenderKeyToServer(senderKeyRecord: ApiSenderKeyRecord) { - val currentUser = userPreferences.currentUser ?: return - val uniqueDocId = "${senderKeyRecord.deviceId}-${senderKeyRecord.distributionId}" - spaceSenderKeyRecordRef(senderKeyRecord.distributionId, currentUser.id) - .document(uniqueDocId).set(senderKeyRecord).await() + try { + val currentUser = userPreferences.currentUser ?: return + val uniqueDocId = "${senderKeyRecord.deviceId}-${senderKeyRecord.distributionId}" + spaceSenderKeyRecordRef(senderKeyRecord.distributionId, currentUser.id) + .document(uniqueDocId).set(senderKeyRecord).await() + } catch (e: Exception) { + Timber.e(e, "Failed to save sender key to server: $senderKeyRecord") + } } override fun storeSenderKey( @@ -56,12 +64,12 @@ class BufferedSenderKeyStore @Inject constructor( record: SenderKeyRecord ) { val key = StoreKey(sender, distributionId, sender.deviceId) - if (inMemoryStore.any { it.key.address.deviceId == sender.deviceId && it.key.distributionId == distributionId }) { + if (inMemoryStore.containsKey(key)) { return } inMemoryStore[key] = record - runBlocking { + CoroutineScope(appDispatcher.IO).launch { senderKeyDao.insertSenderKey( SenderKeyEntity( address = sender.name, @@ -134,7 +142,7 @@ class BufferedSenderKeyStore @Inject constructor( override fun clearSenderKeySharedWith(addresses: Collection?) { addresses?.forEach { address -> - address?.let { sharedWithAddresses.add(it) } + address?.let { sharedWithAddresses.remove(it) } } } diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/database/AppDatabase.kt b/data/src/main/java/com/canopas/yourspace/data/storage/database/AppDatabase.kt index 66aa1313..0b00b599 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/database/AppDatabase.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/database/AppDatabase.kt @@ -2,6 +2,8 @@ package com.canopas.yourspace.data.storage.database import androidx.room.Database import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [SenderKeyEntity::class], @@ -11,3 +13,9 @@ import androidx.room.RoomDatabase abstract class AppDatabase : RoomDatabase() { abstract fun senderKeyDao(): SenderKeyDao } + +val MIGRATION_1_1 = object : Migration(1, 1) { + override fun migrate(db: SupportSQLiteDatabase) { + // No changes required for now. + } +} diff --git a/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt index 78531780..341e99c0 100644 --- a/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt +++ b/data/src/main/java/com/canopas/yourspace/data/storage/database/SenderKeyDao.kt @@ -5,6 +5,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import org.signal.libsignal.protocol.groups.state.SenderKeyRecord +import timber.log.Timber @Dao interface SenderKeyDao { @@ -37,6 +38,7 @@ interface SenderKeyDao { try { SenderKeyRecord(it.record) } catch (e: Exception) { + Timber.e(e, "Failed to create SenderKeyRecord.") null } } 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 32ff02be..84a149ec 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 @@ -45,7 +45,7 @@ object EphemeralECDHUtils { val cipherKey: ByteArray = computeCipherKey(masterSecret, syntheticIv) val cipher = Cipher.getInstance("AES/CTR/NoPadding") - cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16))) + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(syntheticIv)) val cipherText = cipher.doFinal(plaintext) return EncryptedDistribution( @@ -81,7 +81,7 @@ object EphemeralECDHUtils { val cipherKey = mac.doFinal(syntheticIv) val cipher = Cipher.getInstance("AES/CTR/NoPadding") - cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(ByteArray(16))) + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(cipherKey, "AES"), IvParameterSpec(syntheticIv)) val plaintext = cipher.doFinal(cipherText) mac.init(SecretKeySpec(masterSecret, "HmacSHA256")) 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 abd45f43..a4435329 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 @@ -12,7 +12,7 @@ import kotlin.random.Random private const val AES_ALGORITHM = "AES/GCM/NoPadding" private const val KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256" private const val KEY_SIZE = 256 // bits -private const val ITERATION_COUNT = 10000 +private const val ITERATION_COUNT = 100000 private const val GCM_IV_SIZE = 12 // bytes private const val GCM_TAG_SIZE = 128 // bits diff --git a/firestore.rules b/firestore.rules index 6bb59d5b..4efe4bcd 100644 --- a/firestore.rules +++ b/firestore.rules @@ -138,11 +138,6 @@ service cloud.firestore { allow delete: if isAuthorized() && isSpaceMember(spaceId); } - match /{path=**}/space_places/{place} { - allow read: if isAuthorized() && isSpaceMember(resource.data.space_id); - allow write: if false; - } - match /spaces/{spaceId}/space_places/{place} { allow read: if isAuthorized() && isSpaceMember(spaceId); allow delete: if isAuthorized() && @@ -220,7 +215,7 @@ service cloud.firestore { match /spaces/{spaceId}/space_members/{docId} { match /user_locations/{docId} { allow read: if isAuthorized() && - (request.auth.uid == resource.data.user_id || readUserLocation()); + (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; allow create: if isAuthorized() && @@ -235,8 +230,8 @@ service cloud.firestore { match /user_journeys/{docId} { allow read: if isAuthorized() && - (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if true; + (request.auth.uid == resource.data.user_id || isSpaceMember(spaceId)); + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && From 43e054096a7d7bb21eb0c332c8473deeb3128dd7 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Fri, 3 Jan 2025 15:33:26 +0530 Subject: [PATCH 18/30] PR changes --- .../journey/components/LocationHistory.kt | 2 +- .../journey/detail/UserJourneyDetailScreen.kt | 2 +- .../timeline/JourneyTimelineViewModel.kt | 6 +- .../ui/flow/pin/enterpin/EnterPinViewModel.kt | 8 +- .../ui/flow/pin/setpin/SetPinViewModel.kt | 6 +- .../data/models/location/LocationJourney.kt | 96 ++++++++++++++++++- .../data/repository/JourneyRepository.kt | 18 ++-- .../data/service/auth/AuthService.kt | 3 - .../service/location/ApiJourneyService.kt | 95 +----------------- .../service/location/ApiLocationService.kt | 9 +- 10 files changed, 123 insertions(+), 122 deletions(-) diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/components/LocationHistory.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/components/LocationHistory.kt index 28ebeba3..4634c46d 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/components/LocationHistory.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/components/LocationHistory.kt @@ -113,7 +113,7 @@ fun JourneyLocationItem( .padding(start = 16.dp) .weight(1f) ) { - val time = getFormattedJourneyTime(location.created_at ?: 0, location.update_at ?: 0) + val time = getFormattedJourneyTime(location.created_at ?: 0, location.updated_at ?: 0) val distance = getDistanceString(location.route_distance ?: 0.0) PlaceInfo(title, "$time - $distance") diff --git a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailScreen.kt b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailScreen.kt index c42f9381..8c23a44d 100644 --- a/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailScreen.kt +++ b/app/src/main/java/com/canopas/yourspace/ui/flow/journey/detail/UserJourneyDetailScreen.kt @@ -174,7 +174,7 @@ private fun JourneyInfo(journey: LocationJourney) { .padding(start = 16.dp) .weight(1f) ) { - PlaceInfo(toAddressStr, getFormattedLocationTime(journey.update_at!!)) + PlaceInfo(toAddressStr, getFormattedLocationTime(journey.updated_at!!)) } } } 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 fb488d7b..13801a08 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 @@ -98,7 +98,7 @@ class JourneyTimelineViewModel @Inject constructor( try { val from = _state.value.selectedTimeFrom val to = _state.value.selectedTimeTo - val lastJourneyTime = allJourneys.minOfOrNull { it.update_at!! } + val lastJourneyTime = allJourneys.minOfOrNull { it.updated_at!! } val locations = if (loadMore) { journeyService.getMoreJourneyHistory(userId, lastJourneyTime) @@ -108,7 +108,7 @@ class JourneyTimelineViewModel @Inject constructor( val filteredLocations = locations.filter { (it.created_at?.let { created -> created in from..to } ?: false) || - (it.update_at?.let { updated -> updated in from..to } ?: false) + (it.updated_at?.let { updated -> updated in from..to } ?: false) } val locationJourneys = (allJourneys + filteredLocations).groupByDate() @@ -177,7 +177,7 @@ class JourneyTimelineViewModel @Inject constructor( private fun List.groupByDate(): Map> { val journeys = this.distinctBy { it.id } - .sortedByDescending { it.update_at!! } + .sortedByDescending { it.updated_at!! } val groupedItems = mutableMapOf>() 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 6fbe2f5d..f2c7522b 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 @@ -41,9 +41,9 @@ class EnterPinViewModel @Inject constructor( private fun validatePin() { val pin = state.value.pin if (pin.length < 4) { - _state.value = _state.value.copy(pinError = PinErrorState.LENGTH_ERROR) - } else if (pin.length == 4 && !pin.contains(Regex("[0-9]"))) { - _state.value = _state.value.copy(pinError = PinErrorState.CHARACTERS_ERROR) + _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) } } @@ -73,7 +73,7 @@ class EnterPinViewModel @Inject constructor( inclusive = true ) } else { - _state.value = _state.value.copy(pinError = PinErrorState.INVALID_PIN) + _state.value = _state.value.copy(pinError = PinErrorState.INVALID_PIN, showLoader = false) } } } 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 e632fa64..590d37b2 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 @@ -49,9 +49,9 @@ class SetPinViewModel @Inject constructor( private fun validatePin() { val pin = state.value.pin if (pin.length < 4) { - _state.value = _state.value.copy(pinError = PinErrorState.LENGTH_ERROR) + _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) + _state.value = _state.value.copy(pinError = PinErrorState.CHARACTERS_ERROR, showLoader = false) } else { _state.value = _state.value.copy(pinError = null) } @@ -113,7 +113,7 @@ class SetPinViewModel @Inject constructor( ) } } catch (e: Exception) { - _state.value = _state.value.copy(error = e) + _state.value = _state.value.copy(error = e, showLoader = false) } } } 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 4fca5909..cec877c8 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,6 +5,7 @@ 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 @@ -20,7 +21,7 @@ data class LocationJourney( val route_duration: Long? = null, val routes: List = emptyList(), val created_at: Long = System.currentTimeMillis(), - val update_at: Long = System.currentTimeMillis() + val updated_at: Long = System.currentTimeMillis() ) @Keep @@ -88,3 +89,96 @@ fun Location.toLocationJourney(userId: String, journeyId: String) = LocationJour from_latitude = latitude, from_longitude = longitude ) + +/** + * Convert an [EncryptedLocationJourney] to a [LocationJourney] using the provided [GroupCipher] + */ +fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher): LocationJourney { + val decryptedFromLat = groupCipher.decrypt(encrypted_from_latitude.toBytes()) + val decryptedFromLong = groupCipher.decrypt(encrypted_from_longitude.toBytes()) + val decryptedToLat = encrypted_to_latitude?.let { groupCipher.decrypt(it.toBytes()) } + val decryptedToLong = encrypted_to_longitude?.let { groupCipher.decrypt(it.toBytes()) } + + val decryptedRoutes = encrypted_routes.map { + JourneyRoute( + latitude = groupCipher.decrypt(it.encrypted_latitude.toBytes()) + .toString(Charsets.UTF_8).toDouble(), + longitude = groupCipher.decrypt(it.encrypted_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 + ) +} + +/** + * 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( + encrypted_latitude = Blob.fromBytes( + groupCipher.encrypt( + distributionId, + it.latitude.toString().toByteArray(Charsets.UTF_8) + ).serialize() + ), + encrypted_longitude = Blob.fromBytes( + groupCipher.encrypt( + distributionId, + it.longitude.toString().toByteArray(Charsets.UTF_8) + ).serialize() + ) + ) + } + + return EncryptedLocationJourney( + id = id, + user_id = user_id, + encrypted_from_latitude = Blob.fromBytes(encryptedFromLat.serialize()), + encrypted_from_longitude = Blob.fromBytes(encryptedFromLong.serialize()), + encrypted_to_latitude = encryptedToLat?.let { Blob.fromBytes(it.serialize()) }, + encrypted_to_longitude = encryptedToLong?.let { Blob.fromBytes(it.serialize()) }, + route_distance = route_distance, + route_duration = route_duration, + encrypted_routes = encryptedRoutes, + created_at = created_at, + updated_at = updated_at + ) +} diff --git a/data/src/main/java/com/canopas/yourspace/data/repository/JourneyRepository.kt b/data/src/main/java/com/canopas/yourspace/data/repository/JourneyRepository.kt index 906dcd7e..62b54267 100644 --- a/data/src/main/java/com/canopas/yourspace/data/repository/JourneyRepository.kt +++ b/data/src/main/java/com/canopas/yourspace/data/repository/JourneyRepository.kt @@ -83,7 +83,7 @@ class JourneyRepository @Inject constructor( lastKnownJourney: LocationJourney ): Boolean { val calendar = Calendar.getInstance() - calendar.timeInMillis = lastKnownJourney.update_at!! + calendar.timeInMillis = lastKnownJourney.updated_at!! val lastKnownDay = calendar.get(Calendar.DAY_OF_MONTH) calendar.timeInMillis = extractedLocation?.time ?: System.currentTimeMillis() val currentDay = calendar.get(Calendar.DAY_OF_MONTH) @@ -94,7 +94,7 @@ class JourneyRepository @Inject constructor( extractedLocation: Location? = null, lastKnownJourney: LocationJourney ): Boolean { - val lastKnownDate = Instant.ofEpochMilli(lastKnownJourney.update_at!!) + val lastKnownDate = Instant.ofEpochMilli(lastKnownJourney.updated_at!!) .atZone(ZoneId.systemDefault()) .toLocalDate() val currentDate = @@ -135,7 +135,7 @@ class JourneyRepository @Inject constructor( lastKnownJourney: LocationJourney ) { val updatedJourney = lastKnownJourney.copy( - update_at = System.currentTimeMillis() + updated_at = System.currentTimeMillis() ) journeyService.updateLastLocationJourney( userId = userId, @@ -211,7 +211,7 @@ class JourneyRepository @Inject constructor( } val timeDifference = - (geometricMedian?.time ?: extractedLocation.time) - lastKnownJourney.update_at!! + (geometricMedian?.time ?: extractedLocation.time) - lastKnownJourney.updated_at!! if (lastKnownJourney.isSteadyLocation()) { // Handle steady user @@ -309,12 +309,12 @@ class JourneyRepository @Inject constructor( to_latitude = extractedLocation.latitude, to_longitude = extractedLocation.longitude, route_distance = distance.toDouble() + (lastKnownJourney.route_distance ?: 0.0), - route_duration = (lastKnownJourney.update_at!! - lastKnownJourney.created_at!!), + route_duration = (lastKnownJourney.updated_at!! - lastKnownJourney.created_at!!), routes = lastKnownJourney.routes + listOf(extractedLocation.toRoute()), created_at = lastKnownJourney.created_at ) val lastJourneyUpdatedTime = locationCache.getLastJourneyUpdatedTime(userId) - val timeDifference = journey.update_at!! - lastJourneyUpdatedTime + val timeDifference = journey.updated_at!! - lastJourneyUpdatedTime if (timeDifference >= MIN_UPDATE_INTERVAL_MS) { // Update last location journey in remote database // as one minute is passed since last update @@ -344,10 +344,10 @@ class JourneyRepository @Inject constructor( to_latitude = extractedLocation.latitude, to_longitude = extractedLocation.longitude, route_distance = distance.toDouble() + (lastKnownJourney.route_distance ?: 0.0), - route_duration = (lastKnownJourney.update_at!! - lastKnownJourney.created_at!!), + route_duration = (lastKnownJourney.updated_at!! - lastKnownJourney.created_at!!), routes = lastKnownJourney.routes + listOf(extractedLocation.toRoute()), created_at = lastKnownJourney.created_at, - update_at = lastKnownJourney.update_at + updated_at = lastKnownJourney.updated_at ) journeyService.updateLastLocationJourney( userId = userId, @@ -360,7 +360,7 @@ class JourneyRepository @Inject constructor( userId = userId, fromLatitude = extractedLocation.latitude, fromLongitude = extractedLocation.longitude, - createdAt = lastKnownJourney.update_at + createdAt = lastKnownJourney.updated_at ) { newJourneyId = it } 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 47c442e0..26bcbd53 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 @@ -116,9 +116,6 @@ class AuthService @Inject constructor( locationCache.clear() } catch (e: Exception) { throw SecurityException("Failed to completely sign out. Some sensitive data might not be cleared.") - } finally { - // Force garbage collection to clean up any sensitive data in memory - System.gc() } } 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 b4b56e70..607a525c 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 @@ -1,9 +1,10 @@ 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.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 @@ -14,7 +15,6 @@ 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.PrivateKeyUtils -import com.google.firebase.firestore.Blob import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.Query import com.google.firebase.firestore.toObject @@ -81,7 +81,7 @@ class ApiJourneyService @Inject constructor( val groupCipher = GroupCipher(bufferedSenderKeyStore, groupAddress) return 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") return null } } @@ -149,7 +149,7 @@ class ApiJourneyService @Inject constructor( route_duration = routeDuration, routes = routes, created_at = createdAt ?: System.currentTimeMillis(), - update_at = updateAt ?: System.currentTimeMillis() + updated_at = updateAt ?: System.currentTimeMillis() ) val docRef = spaceMemberJourneyRef(spaceId, userId).document(journey.id) @@ -162,93 +162,6 @@ class ApiJourneyService @Inject constructor( } } - private 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( - encrypted_latitude = Blob.fromBytes( - groupCipher.encrypt( - distributionId, - it.latitude.toString().toByteArray(Charsets.UTF_8) - ).serialize() - ), - encrypted_longitude = Blob.fromBytes( - groupCipher.encrypt( - distributionId, - it.longitude.toString().toByteArray(Charsets.UTF_8) - ).serialize() - ) - ) - } - - return EncryptedLocationJourney( - id = id, - user_id = user_id, - encrypted_from_latitude = Blob.fromBytes(encryptedFromLat.serialize()), - encrypted_from_longitude = Blob.fromBytes(encryptedFromLong.serialize()), - encrypted_to_latitude = encryptedToLat?.let { Blob.fromBytes(it.serialize()) }, - encrypted_to_longitude = encryptedToLong?.let { Blob.fromBytes(it.serialize()) }, - route_distance = route_distance, - route_duration = route_duration, - encrypted_routes = encryptedRoutes, - created_at = created_at, - updated_at = update_at - ) - } - - private fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher): LocationJourney { - val decryptedFromLat = groupCipher.decrypt(encrypted_from_latitude.toBytes()) - val decryptedFromLong = groupCipher.decrypt(encrypted_from_longitude.toBytes()) - val decryptedToLat = encrypted_to_latitude?.let { groupCipher.decrypt(it.toBytes()) } - val decryptedToLong = encrypted_to_longitude?.let { groupCipher.decrypt(it.toBytes()) } - - val decryptedRoutes = encrypted_routes.map { - JourneyRoute( - latitude = groupCipher.decrypt(it.encrypted_latitude.toBytes()) - .toString(Charsets.UTF_8).toDouble(), - longitude = groupCipher.decrypt(it.encrypted_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, - update_at = updated_at - ) - } - suspend fun updateLastLocationJourney(userId: String, journey: LocationJourney) { userPreferences.currentUser?.space_ids?.forEach { spaceId -> val cipherAndMessage = getGroupCipherAndDistributionMessage(spaceId, userId) ?: run { 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 ffda51f8..d8b725a4 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 @@ -136,18 +136,15 @@ class ApiLocationService @Inject constructor( } catch (e: Exception) { when (e) { is NoSessionException -> { - Timber.e("No session found for userId: $userId in spaceId: $spaceId. Skipping save.") + Timber.e("No session found. Skipping save.") } is InvalidSenderKeySessionException -> { - Timber.e("Invalid sender key session for userId: $userId in spaceId: $spaceId. Skipping save.") + Timber.e("Invalid sender key session. Skipping save.") } else -> { - Timber.e( - e, - "Failed to save encrypted location for userId: $userId in spaceId: $spaceId" - ) + Timber.e(e, "Failed to save encrypted location") } } } From d9901d60272ede9659fb1e9a98bf275c1cbdf73c Mon Sep 17 00:00:00 2001 From: cp-megh Date: Mon, 6 Jan 2025 17:34:13 +0530 Subject: [PATCH 19/30] PR changes --- .../ui/flow/pin/enterpin/EnterPinScreen.kt | 30 +++++++------ .../ui/flow/pin/setpin/SetPinScreen.kt | 29 ++++++------- .../data/models/location/ApiLocation.kt | 4 +- .../data/models/location/LocationJourney.kt | 42 +++++++++---------- .../service/location/ApiLocationService.kt | 8 ++-- firestore.rules | 18 ++++---- 6 files changed, 63 insertions(+), 68 deletions(-) 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 7835c201..926143d3 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 @@ -14,7 +14,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -53,15 +52,6 @@ private fun EnterPinContent(modifier: Modifier) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() val context = LocalContext.current - var pinErrorText = "" - LaunchedEffect(state.pinError) { - 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 -> "" - } - } Column( modifier = modifier @@ -96,12 +86,20 @@ private fun EnterPinContent(modifier: Modifier) { Spacer(modifier = Modifier.height(16.dp)) - Text( - text = pinErrorText, - color = if (pinErrorText.isNotEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.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 -> "" + } + 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)) 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 8d2575cf..55ead0f8 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 @@ -14,7 +14,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -53,15 +52,6 @@ private fun SetPinContent(modifier: Modifier) { val viewModel = hiltViewModel() val state by viewModel.state.collectAsState() val context = LocalContext.current - var pinErrorText = "" - - LaunchedEffect(state.pinError) { - 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 -> "" - } - } Column( modifier = modifier @@ -96,12 +86,19 @@ private fun SetPinContent(modifier: Modifier) { Spacer(modifier = Modifier.height(16.dp)) - Text( - text = pinErrorText, - color = if (pinErrorText.isNotEmpty()) MaterialTheme.colorScheme.error else Color.Transparent, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.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)) diff --git a/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt b/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt index 78b7c0c0..ec904edc 100644 --- a/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt +++ b/data/src/main/java/com/canopas/yourspace/data/models/location/ApiLocation.kt @@ -20,8 +20,8 @@ data class ApiLocation( data class EncryptedApiLocation( val id: String = UUID.randomUUID().toString(), val user_id: String = "", - val encrypted_latitude: Blob = Blob.fromBytes(ByteArray(0)), // Base64 encoded encrypted latitude - val encrypted_longitude: Blob = Blob.fromBytes(ByteArray(0)), // Base64 encoded encrypted longitude + val latitude: Blob = Blob.fromBytes(ByteArray(0)), // Base64 encoded encrypted latitude + val longitude: Blob = Blob.fromBytes(ByteArray(0)), // Base64 encoded encrypted longitude val created_at: Long = System.currentTimeMillis() ) 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 cec877c8..6b59cdac 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 @@ -29,13 +29,13 @@ data class LocationJourney( data class EncryptedLocationJourney( val id: String = UUID.randomUUID().toString(), val user_id: String = "", - val encrypted_from_latitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted latitude - from - val encrypted_from_longitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted longitude - from - val encrypted_to_latitude: Blob? = null, // Encrypted latitude - to - val encrypted_to_longitude: Blob? = null, // Encrypted longitude - to + val from_latitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted latitude - from + val from_longitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted longitude - from + val to_latitude: Blob? = null, // Encrypted latitude - to + val to_longitude: Blob? = null, // Encrypted longitude - to val route_distance: Double? = null, val route_duration: Long? = null, - val encrypted_routes: List = emptyList(), // Encrypted journey routes + val routes: List = emptyList(), // Encrypted journey routes val created_at: Long = System.currentTimeMillis(), val updated_at: Long = System.currentTimeMillis() ) @@ -46,8 +46,8 @@ data class JourneyRoute(val latitude: Double = 0.0, val longitude: Double = 0.0) @Keep @JsonClass(generateAdapter = true) data class EncryptedJourneyRoute( - val encrypted_latitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted latitude - val encrypted_longitude: Blob = Blob.fromBytes(ByteArray(0)) // Encrypted longitude + val latitude: Blob = Blob.fromBytes(ByteArray(0)), // Encrypted latitude + val longitude: Blob = Blob.fromBytes(ByteArray(0)) // Encrypted longitude ) fun Location.toRoute(): JourneyRoute { @@ -94,16 +94,16 @@ fun Location.toLocationJourney(userId: String, journeyId: String) = LocationJour * Convert an [EncryptedLocationJourney] to a [LocationJourney] using the provided [GroupCipher] */ fun EncryptedLocationJourney.toDecryptedLocationJourney(groupCipher: GroupCipher): LocationJourney { - val decryptedFromLat = groupCipher.decrypt(encrypted_from_latitude.toBytes()) - val decryptedFromLong = groupCipher.decrypt(encrypted_from_longitude.toBytes()) - val decryptedToLat = encrypted_to_latitude?.let { groupCipher.decrypt(it.toBytes()) } - val decryptedToLong = encrypted_to_longitude?.let { groupCipher.decrypt(it.toBytes()) } + 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 = encrypted_routes.map { + val decryptedRoutes = routes.map { JourneyRoute( - latitude = groupCipher.decrypt(it.encrypted_latitude.toBytes()) + latitude = groupCipher.decrypt(it.latitude.toBytes()) .toString(Charsets.UTF_8).toDouble(), - longitude = groupCipher.decrypt(it.encrypted_longitude.toBytes()) + longitude = groupCipher.decrypt(it.longitude.toBytes()) .toString(Charsets.UTF_8).toDouble() ) } @@ -153,13 +153,13 @@ fun LocationJourney.toEncryptedLocationJourney( val encryptedRoutes = routes.map { EncryptedJourneyRoute( - encrypted_latitude = Blob.fromBytes( + latitude = Blob.fromBytes( groupCipher.encrypt( distributionId, it.latitude.toString().toByteArray(Charsets.UTF_8) ).serialize() ), - encrypted_longitude = Blob.fromBytes( + longitude = Blob.fromBytes( groupCipher.encrypt( distributionId, it.longitude.toString().toByteArray(Charsets.UTF_8) @@ -171,13 +171,13 @@ fun LocationJourney.toEncryptedLocationJourney( return EncryptedLocationJourney( id = id, user_id = user_id, - encrypted_from_latitude = Blob.fromBytes(encryptedFromLat.serialize()), - encrypted_from_longitude = Blob.fromBytes(encryptedFromLong.serialize()), - encrypted_to_latitude = encryptedToLat?.let { Blob.fromBytes(it.serialize()) }, - encrypted_to_longitude = encryptedToLong?.let { Blob.fromBytes(it.serialize()) }, + 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, - encrypted_routes = encryptedRoutes, + routes = encryptedRoutes, created_at = created_at, updated_at = updated_at ) 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 d8b725a4..4632db19 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 @@ -127,8 +127,8 @@ class ApiLocationService @Inject constructor( val location = EncryptedApiLocation( user_id = userId, - encrypted_latitude = Blob.fromBytes(encryptedLatitude.serialize()), - encrypted_longitude = Blob.fromBytes(encryptedLongitude.serialize()), + latitude = Blob.fromBytes(encryptedLatitude.serialize()), + longitude = Blob.fromBytes(encryptedLongitude.serialize()), created_at = recordedAt ) @@ -175,9 +175,9 @@ class ApiLocationService @Inject constructor( getGroupCipherAndDistributionMessage(currentSpaceId, userId)?.second ?: return null return try { - val latitudeBytes = groupCipher.decrypt(encryptedLocation.encrypted_latitude.toBytes()) + val latitudeBytes = groupCipher.decrypt(encryptedLocation.latitude.toBytes()) val longitudeBytes = - groupCipher.decrypt(encryptedLocation.encrypted_longitude.toBytes()) + groupCipher.decrypt(encryptedLocation.longitude.toBytes()) val latitude = latitudeBytes.toString(Charsets.UTF_8).toDoubleOrNull() val longitude = longitudeBytes.toString(Charsets.UTF_8).toDoubleOrNull() diff --git a/firestore.rules b/firestore.rules index 4efe4bcd..9f7db645 100644 --- a/firestore.rules +++ b/firestore.rules @@ -47,7 +47,7 @@ service cloud.firestore { request.resource.data.get('fcm_token', '') is string && request.resource.data.get('profile_image', '') is string && request.resource.data.get('space_ids', []) is list; - allow update: if true; + allow update: if isAuthorized() && request.auth.uid == resource.data.id; allow delete: if isAuthorized() && request.auth.uid == resource.data.id; allow read: if isAuthorized(); @@ -69,7 +69,7 @@ service cloud.firestore { match /user_journeys/{docId} { allow read: if isAuthorized() && (request.auth.uid == resource.data.user_id || readUserLocation()); - allow update: if true; + allow update: if isAuthorized() && request.auth.uid == resource.data.user_id; allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && @@ -115,7 +115,7 @@ service cloud.firestore { } match /spaces/{spaceId} { - allow read: if true; + allow read: if isAuthorized() && isSpaceMember(spaceId); allow delete: if isAuthorized() && request.auth.uid == resource.data.admin_id; allow update: if isAuthorized() && request.auth.uid == resource.data.admin_id; allow create: if isAuthorized() && @@ -220,11 +220,11 @@ service cloud.firestore { allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "encrypted_latitude", "encrypted_longitude", "created_at"]) && + request.resource.data.keys().hasAll(["id", "user_id", "latitude", "longitude", "created_at"]) && request.resource.data.id is string && request.resource.data.user_id is string && - request.resource.data.encrypted_latitude is bytes && - request.resource.data.encrypted_longitude is bytes && + request.resource.data.latitude is bytes && + request.resource.data.longitude is bytes && request.resource.data.created_at is int; } @@ -235,11 +235,11 @@ service cloud.firestore { allow delete: if isAuthorized() && request.auth.uid == resource.data.user_id; allow create: if isAuthorized() && request.auth.uid == request.resource.data.user_id && - request.resource.data.keys().hasAll(["id", "user_id", "encrypted_from_latitude", "encrypted_from_longitude", "created_at"]) && + request.resource.data.keys().hasAll(["id", "user_id", "from_latitude", "from_longitude", "created_at"]) && request.resource.data.id is string && request.resource.data.user_id is string && - request.resource.data.encrypted_from_latitude is bytes && - request.resource.data.encrypted_from_longitude is bytes && + request.resource.data.from_latitude is bytes && + request.resource.data.from_longitude is bytes && request.resource.data.created_at is int; } From 33bf8328fe6f457e66f3ada12758fd6d73bd34ac Mon Sep 17 00:00:00 2001 From: cp-megh Date: Mon, 6 Jan 2025 17:42:24 +0530 Subject: [PATCH 20/30] merge main --- .../yourspace/data/repository/JourneyRepository.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/data/src/main/java/com/canopas/yourspace/data/repository/JourneyRepository.kt b/data/src/main/java/com/canopas/yourspace/data/repository/JourneyRepository.kt index fac8c3de..58dea8b1 100644 --- a/data/src/main/java/com/canopas/yourspace/data/repository/JourneyRepository.kt +++ b/data/src/main/java/com/canopas/yourspace/data/repository/JourneyRepository.kt @@ -83,7 +83,7 @@ class JourneyRepository @Inject constructor( lastKnownJourney: LocationJourney ): Boolean { val calendar = Calendar.getInstance() - calendar.timeInMillis = lastKnownJourney.updated_at!! + calendar.timeInMillis = lastKnownJourney.updated_at val lastKnownDay = calendar.get(Calendar.DAY_OF_MONTH) calendar.timeInMillis = extractedLocation?.time ?: System.currentTimeMillis() val currentDay = calendar.get(Calendar.DAY_OF_MONTH) @@ -94,7 +94,7 @@ class JourneyRepository @Inject constructor( extractedLocation: Location? = null, lastKnownJourney: LocationJourney ): Boolean { - val lastKnownDate = Instant.ofEpochMilli(lastKnownJourney.updated_at!!) + val lastKnownDate = Instant.ofEpochMilli(lastKnownJourney.updated_at) .atZone(ZoneId.systemDefault()) .toLocalDate() val currentDate = @@ -213,7 +213,7 @@ class JourneyRepository @Inject constructor( } val timeDifference = - (geometricMedian?.time ?: extractedLocation.time) - lastKnownJourney.updated_at!! + (geometricMedian?.time ?: extractedLocation.time) - lastKnownJourney.updated_at if (lastKnownJourney.type == JourneyType.STEADY) { // Handle steady user @@ -312,13 +312,13 @@ class JourneyRepository @Inject constructor( to_latitude = extractedLocation.latitude, to_longitude = extractedLocation.longitude, route_distance = distance.toDouble() + (lastKnownJourney.route_distance ?: 0.0), - route_duration = (lastKnownJourney.updated_at!! - lastKnownJourney.created_at!!), + route_duration = (lastKnownJourney.updated_at - lastKnownJourney.created_at), routes = lastKnownJourney.routes + listOf(extractedLocation.toRoute()), created_at = lastKnownJourney.created_at, type = JourneyType.MOVING ) val lastJourneyUpdatedTime = locationCache.getLastJourneyUpdatedTime(userId) - val timeDifference = journey.updated_at!! - lastJourneyUpdatedTime + val timeDifference = journey.updated_at - lastJourneyUpdatedTime if (timeDifference >= MIN_UPDATE_INTERVAL_MS) { // Update last location journey in remote database // as one minute is passed since last update @@ -348,7 +348,7 @@ class JourneyRepository @Inject constructor( to_latitude = extractedLocation.latitude, to_longitude = extractedLocation.longitude, route_distance = distance.toDouble() + (lastKnownJourney.route_distance ?: 0.0), - route_duration = (lastKnownJourney.updated_at!! - lastKnownJourney.created_at!!), + route_duration = (lastKnownJourney.updated_at - lastKnownJourney.created_at), routes = lastKnownJourney.routes + listOf(extractedLocation.toRoute()), created_at = lastKnownJourney.created_at, updated_at = lastKnownJourney.updated_at, @@ -410,7 +410,7 @@ class JourneyRepository @Inject constructor( lastJourney?.let { journey -> if (from != null && to != null) { val calendar = Calendar.getInstance() - calendar.timeInMillis = journey.created_at!! + calendar.timeInMillis = journey.created_at val lastKnownDay = calendar.get(Calendar.DAY_OF_MONTH) calendar.timeInMillis = from val fromDay = calendar.get(Calendar.DAY_OF_MONTH) From e7a451b87a2c5963fb7714a7d292aab9e2363aac Mon Sep 17 00:00:00 2001 From: cp-megh Date: Mon, 6 Jan 2025 18:00:45 +0530 Subject: [PATCH 21/30] minor change --- .../yourspace/data/service/location/ApiJourneyService.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 fb329dd7..7f22f666 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 @@ -242,7 +242,7 @@ class ApiJourneyService @Inject constructor( val previousDayJourney = spaceMemberJourneyRef(currentSpaceId, userId) .whereEqualTo("user_id", userId) .whereLessThan("created_at", from) - .whereGreaterThanOrEqualTo("update_at", from) + .whereGreaterThanOrEqualTo("updated_at", from) .limit(1) .get() .await() From 65720cdb900d343c049251fb5650c245bd97e1ca Mon Sep 17 00:00:00 2001 From: cp-megh Date: Tue, 7 Jan 2025 16:08:19 +0530 Subject: [PATCH 22/30] 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 23/30] 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 701c297cdc6920708e5f810d1436355d1ea75f3f Mon Sep 17 00:00:00 2001 From: cp-megh Date: Tue, 7 Jan 2025 17:09:16 +0530 Subject: [PATCH 24/30] merge main --- .../data/repository/JourneyGenerator.kt | 36 +++++++++---------- .../service/location/ApiJourneyService.kt | 11 +++--- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/data/src/main/java/com/canopas/yourspace/data/repository/JourneyGenerator.kt b/data/src/main/java/com/canopas/yourspace/data/repository/JourneyGenerator.kt index e87519e2..4d919ade 100644 --- a/data/src/main/java/com/canopas/yourspace/data/repository/JourneyGenerator.kt +++ b/data/src/main/java/com/canopas/yourspace/data/repository/JourneyGenerator.kt @@ -38,7 +38,7 @@ fun getJourney( from_latitude = newLocation.latitude, from_longitude = newLocation.longitude, created_at = System.currentTimeMillis(), - update_at = System.currentTimeMillis(), + updated_at = System.currentTimeMillis(), type = JourneyType.STEADY ) return JourneyResult(null, newSteadyJourney) @@ -67,7 +67,7 @@ fun getJourney( } // 4. Calculate time difference - val timeDifference = newLocation.time - (lastKnownJourney.update_at ?: 0L) + val timeDifference = newLocation.time - lastKnownJourney.updated_at // 5. Check if the day changed val dayChanged = isDayChanged(newLocation, lastKnownJourney) @@ -77,7 +77,7 @@ fun getJourney( val updatedJourney = lastKnownJourney.copy( from_latitude = newLocation.latitude, from_longitude = newLocation.longitude, - update_at = System.currentTimeMillis() + updated_at = System.currentTimeMillis() ) return JourneyResult(updatedJourney, null) } @@ -86,14 +86,14 @@ fun getJourney( // Manage Journey // 1. lastKnownJourney is null, create a new journey // 2. If user is stationary - // a. update the journey with the last location and update the update_at + // a. update the journey with the last location and update the updated_at // b. If distance > 150, - // - update the journey with the last location and update the update_at + // - update the journey with the last location and update the updated_at // - create a new moving journey // 3. If user is moving - // a. If distance > 150, update the last location, route and update_at + // a. If distance > 150, update the last location, route and updated_at // b. If distance < 150 and time diff between two location updates > 5 mins, - // - update the journey with the last location and update the update_at, and stop the journey + // - update the journey with the last location and update the updated_at, and stop the journey // - create a new stationary journey // ----------------------------------------------------------------- if (lastKnownJourney.isSteady()) { @@ -105,7 +105,7 @@ fun getJourney( val updatedJourney = lastKnownJourney.copy( from_latitude = newLocation.latitude, from_longitude = newLocation.longitude, - update_at = System.currentTimeMillis() + updated_at = System.currentTimeMillis() ) // 2. Create NEW MOVING journey @@ -119,7 +119,7 @@ fun getJourney( route_distance = distance, route_duration = timeDifference, created_at = System.currentTimeMillis(), - update_at = System.currentTimeMillis(), + updated_at = System.currentTimeMillis(), type = JourneyType.MOVING ) return JourneyResult(updatedJourney, newMovingJourney) @@ -129,7 +129,7 @@ fun getJourney( val updatedJourney = lastKnownJourney.copy( from_latitude = newLocation.latitude, from_longitude = newLocation.longitude, - update_at = System.currentTimeMillis() + updated_at = System.currentTimeMillis() ) return JourneyResult(updatedJourney, null) } @@ -143,8 +143,8 @@ fun getJourney( to_latitude = newLocation.latitude, to_longitude = newLocation.longitude, route_distance = distance, - route_duration = (lastKnownJourney.update_at ?: System.currentTimeMillis()) - - (lastKnownJourney.created_at ?: System.currentTimeMillis()), + route_duration = lastKnownJourney.updated_at - + lastKnownJourney.created_at, routes = lastKnownJourney.routes + newLocation.toRoute() ) @@ -153,8 +153,8 @@ fun getJourney( user_id = userId, from_latitude = newLocation.latitude, from_longitude = newLocation.longitude, - created_at = lastKnownJourney.update_at ?: System.currentTimeMillis(), - update_at = System.currentTimeMillis(), + created_at = lastKnownJourney.updated_at, + updated_at = System.currentTimeMillis(), type = JourneyType.STEADY ) return JourneyResult(updatedJourney, newSteadyJourney) @@ -166,10 +166,10 @@ fun getJourney( to_longitude = newLocation.longitude, // Add new distance to previous distance, if you want cumulative route_distance = (lastKnownJourney.route_distance ?: 0.0) + distance, - route_duration = (lastKnownJourney.update_at ?: 0L) - - (lastKnownJourney.created_at ?: 0L), + route_duration = lastKnownJourney.updated_at - + lastKnownJourney.created_at, routes = lastKnownJourney.routes + newLocation.toRoute(), - update_at = System.currentTimeMillis() + updated_at = System.currentTimeMillis() ) return JourneyResult(updatedJourney, null) } @@ -186,7 +186,7 @@ private fun isDayChanged( newLocation: Location, lastKnownJourney: LocationJourney ): Boolean { - val lastMillis = lastKnownJourney.update_at ?: System.currentTimeMillis() + val lastMillis = lastKnownJourney.updated_at val lastCal = Calendar.getInstance().apply { timeInMillis = lastMillis } val lastDay = lastCal.get(Calendar.DAY_OF_YEAR) 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 79113ef1..3bb8fa13 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 @@ -1,8 +1,6 @@ package com.canopas.yourspace.data.service.location import com.canopas.yourspace.data.models.location.EncryptedLocationJourney -import com.canopas.yourspace.data.models.location.JourneyRoute -import com.canopas.yourspace.data.models.location.JourneyType import com.canopas.yourspace.data.models.location.LocationJourney import com.canopas.yourspace.data.models.location.toDecryptedLocationJourney import com.canopas.yourspace.data.models.location.toEncryptedLocationJourney @@ -28,7 +26,6 @@ import org.signal.libsignal.protocol.groups.GroupCipher import org.signal.libsignal.protocol.groups.GroupSessionBuilder import org.signal.libsignal.protocol.message.SenderKeyDistributionMessage import timber.log.Timber -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @@ -122,7 +119,8 @@ class ApiJourneyService @Inject constructor( suspend fun addJourney( userId: String, newJourney: LocationJourney - ) { + ): LocationJourney { + var journey: LocationJourney = newJourney 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") @@ -132,11 +130,14 @@ class ApiJourneyService @Inject constructor( val docRef = spaceMemberJourneyRef(spaceId, userId).document(newJourney.id) + journey = newJourney.copy(id = docRef.id) + val encryptedJourney = - newJourney.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) + journey.toEncryptedLocationJourney(groupCipher, distributionMessage.distributionId) docRef.set(encryptedJourney).await() } + return journey } suspend fun updateJourney(userId: String, journey: LocationJourney) { From a9102a9ec0a8b383a6d42ea603024ba76bdd3b42 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Wed, 8 Jan 2025 16:17:55 +0530 Subject: [PATCH 25/30] 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 26/30] 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 27/30] 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 28/30] 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 29/30] 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 From fc6784bf1e7b15b1bd39a2c051659964d301d973 Mon Sep 17 00:00:00 2001 From: cp-megh Date: Thu, 9 Jan 2025 16:21:04 +0530 Subject: [PATCH 30/30] minor changes --- data/build.gradle.kts | 4 ++-- .../yourspace/data/service/location/ApiJourneyService.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/data/build.gradle.kts b/data/build.gradle.kts index cd6ab7fe..523a621c 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -93,6 +93,6 @@ dependencies { implementation("com.google.android.libraries.places:places:4.0.0") // Signal Protocol - implementation("org.signal:libsignal-client:0.64.1") - implementation("org.signal:libsignal-android:0.64.1") + implementation("org.signal:libsignal-client:0.65.0") + implementation("org.signal:libsignal-android:0.65.0") } 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 32f70e64..efdf64e9 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 @@ -126,7 +126,7 @@ class ApiJourneyService @Inject constructor( 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( privateKey ?: return null, currentUser.identity_key_salt?.toBytes() ?: return null,