From 30fddcc2df607e6da720d376186f2f5ff3e9f48e Mon Sep 17 00:00:00 2001 From: null Date: Fri, 19 Jun 2026 19:52:35 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20E2EE=20=E2=80=94=20Tink=20AEAD,=20Argon?= =?UTF-8?q?2id=20KDF,=20recovery=20phrase,=20encrypted=20Firestore=20field?= =?UTF-8?q?s=20(batch=20v0.2.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration) - Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp - Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read - Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager - Generate recovery phrase during invite creation, display in CreateInviteScreen - Add recovery phrase input to InviteConfirmScreen for encrypted invites - Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery - Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams - Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields --- app/build.gradle.kts | 4 + app/src/main/java/app/closer/CloserApp.kt | 2 + .../closer/core/navigation/AppNavigation.kt | 10 ++ .../app/closer/core/navigation/AppRoute.kt | 1 + .../closer/crypto/CoupleEncryptionManager.kt | 101 +++++++++++ .../java/app/closer/crypto/CoupleKeyStore.kt | 98 +++++++++++ .../java/app/closer/crypto/FieldEncryptor.kt | 51 ++++++ .../app/closer/crypto/RecoveryKeyManager.kt | 140 +++++++++++++++ .../data/remote/FirestoreAnswerDataSource.kt | 55 ++++-- .../data/remote/FirestoreCoupleDataSource.kt | 58 +++++-- .../remote/FirestoreDesireSyncDataSource.kt | 36 +++- .../data/remote/FirestoreHowWellDataSource.kt | 52 +++++- .../data/remote/FirestoreInviteDataSource.kt | 40 +++-- .../FirestoreQuestionThreadDataSource.kt | 66 +++++-- .../remote/FirestoreWheelAnswerDataSource.kt | 24 ++- .../data/repository/CoupleRepositoryImpl.kt | 39 ++++- .../data/repository/InviteRepositoryImpl.kt | 12 +- .../java/app/closer/domain/model/Couple.kt | 7 +- .../java/app/closer/domain/model/Invite.kt | 6 +- .../domain/repository/CoupleRepository.kt | 3 +- .../domain/repository/InviteRepository.kt | 4 +- .../java/app/closer/ui/home/HomeScreen.kt | 6 + .../java/app/closer/ui/home/HomeViewModel.kt | 17 +- .../closer/ui/pairing/CreateInviteScreen.kt | 65 +++++++ .../ui/pairing/CreateInviteViewModel.kt | 5 +- .../closer/ui/pairing/InviteConfirmScreen.kt | 38 +++- .../ui/pairing/InviteConfirmViewModel.kt | 29 +++- .../app/closer/ui/pairing/RecoveryScreen.kt | 164 ++++++++++++++++++ .../closer/ui/pairing/RecoveryViewModel.kt | 75 ++++++++ firestore.rules | 25 ++- 30 files changed, 1138 insertions(+), 95 deletions(-) create mode 100644 app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt create mode 100644 app/src/main/java/app/closer/crypto/CoupleKeyStore.kt create mode 100644 app/src/main/java/app/closer/crypto/FieldEncryptor.kt create mode 100644 app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt create mode 100644 app/src/main/java/app/closer/ui/pairing/RecoveryScreen.kt create mode 100644 app/src/main/java/app/closer/ui/pairing/RecoveryViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2a1cf40..1eadd15c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -130,6 +130,10 @@ dependencies { // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + // E2EE: Google Tink (AEAD) + Bouncy Castle (Argon2id KDF) + implementation("com.google.crypto.tink:tink-android:1.13.0") + implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") + // Debug debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") diff --git a/app/src/main/java/app/closer/CloserApp.kt b/app/src/main/java/app/closer/CloserApp.kt index 21dd7665..fac23c6d 100644 --- a/app/src/main/java/app/closer/CloserApp.kt +++ b/app/src/main/java/app/closer/CloserApp.kt @@ -4,6 +4,7 @@ import android.app.Application import app.closer.core.firebase.FirebaseInitializer import app.closer.data.repository.ActivityProvider import app.closer.domain.security.DeviceIntegrityChecker +import com.google.crypto.tink.aead.AeadConfig import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,6 +22,7 @@ class CloserApp : Application() { override fun onCreate() { super.onCreate() + AeadConfig.register() ActivityProvider.register(this) firebaseInitializer.initialize() appScope.launch { deviceIntegrityChecker.runCheck() } diff --git a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt index 1cf4a831..cccee8ab 100644 --- a/app/src/main/java/app/closer/core/navigation/AppNavigation.kt +++ b/app/src/main/java/app/closer/core/navigation/AppNavigation.kt @@ -43,6 +43,7 @@ import app.closer.ui.pairing.CreateInviteScreen import app.closer.ui.pairing.EmailInviteScreen import app.closer.ui.pairing.InviteConfirmScreen import app.closer.ui.pairing.PairPromptScreen +import app.closer.ui.pairing.RecoveryScreen import app.closer.ui.dates.DateMatchScreen import app.closer.ui.dates.DateMatchesScreen import app.closer.ui.dates.DateBuilderScreen @@ -276,6 +277,15 @@ fun AppNavigation( onBack = navigateBackOrHome ) } + composable(route = AppRoute.RECOVERY) { + RecoveryScreen( + onRecovered = { + navController.navigate(AppRoute.HOME) { + popUpTo(AppRoute.RECOVERY) { inclusive = true } + } + } + ) + } // Wheel / Category Selection composable(route = AppRoute.CATEGORY_PICKER) { diff --git a/app/src/main/java/app/closer/core/navigation/AppRoute.kt b/app/src/main/java/app/closer/core/navigation/AppRoute.kt index a58a3228..3d376555 100644 --- a/app/src/main/java/app/closer/core/navigation/AppRoute.kt +++ b/app/src/main/java/app/closer/core/navigation/AppRoute.kt @@ -49,6 +49,7 @@ object AppRoute { const val CONNECTION_CHALLENGES = "connection_challenges" const val MEMORY_LANE = "memory_lane" const val WAITING_FOR_PARTNER = "waiting_for_partner" + const val RECOVERY = "recovery" // Question thread: coupleId and questionId are required; prevId and nextId are optional. const val QUESTION_THREAD = diff --git a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt new file mode 100644 index 00000000..a9d2f4d9 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt @@ -0,0 +1,101 @@ +package app.closer.crypto + +import app.closer.domain.model.Couple +import com.google.crypto.tink.Aead +import com.google.crypto.tink.KeysetHandle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +enum class EncryptionStatus { + /** Local keyset present — ready to encrypt/decrypt. */ + UNLOCKED, + /** Found keyset in the invite slot; moved to coupleId slot automatically. */ + RECONCILED_FROM_INVITE, + /** encryptionVersion == 1 but no local keyset — prompt for recovery phrase. */ + NEEDS_RECOVERY, + /** encryptionVersion == 0 (old couple) — operates in plaintext passthrough. */ + PLAINTEXT_COUPLE +} + +data class SetupResult( + val handle: KeysetHandle, + val wrapped: RecoveryKeyManager.WrappedKey, + val recoveryPhrase: String +) + +/** + * High-level E2EE orchestration injected into repositories and ViewModels. + * All KDF operations run on [Dispatchers.Default] to keep the main thread free. + */ +@Singleton +class CoupleEncryptionManager @Inject constructor( + private val keyStore: CoupleKeyStore, + private val keyManager: RecoveryKeyManager +) { + /** + * Called by the inviter when creating an invite. + * Generates a new couple keyset + recovery phrase, wraps the keyset, + * and stores it locally under the invite slot (coupleId unknown yet). + */ + suspend fun setupForNewCouple(inviteCode: String): SetupResult = withContext(Dispatchers.Default) { + val phrase = keyManager.generateRecoveryPhrase() + val handle = keyManager.newCoupleKeyset() + val wrapped = keyManager.wrap(handle, phrase) + keyStore.storeInviteKeyset(inviteCode, handle) + SetupResult(handle, wrapped, phrase) + } + + /** + * Called by the acceptor during pairing, and on any device during recovery. + * Derives the wrap key from [phrase], unwraps the keyset, and stores it + * locally under [coupleId]. + * Throws [com.google.crypto.tink.subtle.Validators]-wrapped exception if phrase is wrong. + */ + suspend fun unwrapAndStore( + coupleId: String, + wrapped: RecoveryKeyManager.WrappedKey, + phrase: String + ): Result = withContext(Dispatchers.Default) { + runCatching { + val handle = keyManager.unwrap(wrapped, phrase) + keyStore.storeKeyset(coupleId, handle) + } + } + + /** + * Called on app launch / Home load after the couple doc is resolved. + * Handles inviter reconciliation (flow B′) transparently. + */ + fun checkStatus(couple: Couple): EncryptionStatus { + if (couple.encryptionVersion == 0) return EncryptionStatus.PLAINTEXT_COUPLE + if (keyStore.hasKeyset(couple.id)) return EncryptionStatus.UNLOCKED + if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) { + return EncryptionStatus.RECONCILED_FROM_INVITE + } + return EncryptionStatus.NEEDS_RECOVERY + } + + fun aeadFor(coupleId: String): Aead? = keyStore.aeadFor(coupleId) + + fun isUnlocked(coupleId: String): Boolean = keyStore.hasKeyset(coupleId) + + /** + * Re-wraps the locally-held keyset with a new phrase and returns the new WrappedKey + * so the caller can persist it to Firestore. The old phrase is NOT required. + * Returns failure if no local keyset exists for [coupleId]. + */ + suspend fun rewrapWithNewPhrase( + coupleId: String, + newPhrase: String + ): Result = withContext(Dispatchers.Default) { + runCatching { + val handle = keyStore.loadKeyset(coupleId) + ?: error("No local keyset for $coupleId — cannot change phrase without recovery first") + keyManager.wrap(handle, newPhrase) + } + } + + fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId) +} diff --git a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt new file mode 100644 index 00000000..20390ac5 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt @@ -0,0 +1,98 @@ +package app.closer.crypto + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys +import com.google.crypto.tink.Aead +import com.google.crypto.tink.CleartextKeysetHandle +import com.google.crypto.tink.JsonKeysetReader +import com.google.crypto.tink.JsonKeysetWriter +import com.google.crypto.tink.KeysetHandle +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.ByteArrayOutputStream +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Persists Tink keyset handles in EncryptedSharedPreferences (Keystore-backed). + * Keys are namespaced by coupleId ("keyset_{coupleId}") or invite slot + * ("keyset_invite_{inviteCode}") for the inviter reconciliation path. + */ +@Singleton +class CoupleKeyStore @Inject constructor( + @ApplicationContext context: Context +) { + private val prefs: SharedPreferences = run { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + masterKeyAlias, + "couple_crypto_secure", + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + private val aeadCache = ConcurrentHashMap() + + fun hasKeyset(coupleId: String): Boolean = + prefs.contains(prefKey(coupleId)) + + fun hasInviteKeyset(inviteCode: String): Boolean = + prefs.contains(invitePrefKey(inviteCode)) + + fun storeKeyset(coupleId: String, handle: KeysetHandle) { + val json = serialize(handle) + prefs.edit().putString(prefKey(coupleId), json).apply() + aeadCache[coupleId] = handle.getPrimitive(Aead::class.java) + } + + fun storeInviteKeyset(inviteCode: String, handle: KeysetHandle) { + val json = serialize(handle) + prefs.edit().putString(invitePrefKey(inviteCode), json).apply() + } + + fun loadKeyset(coupleId: String): KeysetHandle? = + load(prefKey(coupleId)) + + fun loadInviteKeyset(inviteCode: String): KeysetHandle? = + load(invitePrefKey(inviteCode)) + + /** Moves the invite-slot keyset to the coupleId slot and removes the invite slot. */ + fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean { + val handle = loadInviteKeyset(inviteCode) ?: return false + storeKeyset(coupleId, handle) + prefs.edit().remove(invitePrefKey(inviteCode)).apply() + return true + } + + fun deleteKeyset(coupleId: String) { + prefs.edit().remove(prefKey(coupleId)).apply() + aeadCache.remove(coupleId) + } + + fun aeadFor(coupleId: String): Aead? { + aeadCache[coupleId]?.let { return it } + val handle = loadKeyset(coupleId) ?: return null + val aead = handle.getPrimitive(Aead::class.java) + aeadCache[coupleId] = aead + return aead + } + + private fun prefKey(coupleId: String) = "keyset_$coupleId" + private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode" + + private fun serialize(handle: KeysetHandle): String { + val baos = ByteArrayOutputStream() + CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(baos)) + return baos.toString(Charsets.UTF_8.name()) + } + + private fun load(key: String): KeysetHandle? = + runCatching { + val json = prefs.getString(key, null) ?: return null + CleartextKeysetHandle.read(JsonKeysetReader.withString(json)) + }.getOrNull() +} diff --git a/app/src/main/java/app/closer/crypto/FieldEncryptor.kt b/app/src/main/java/app/closer/crypto/FieldEncryptor.kt new file mode 100644 index 00000000..2e0556b6 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/FieldEncryptor.kt @@ -0,0 +1,51 @@ +package app.closer.crypto + +import android.util.Base64 +import com.google.crypto.tink.Aead +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Stateless helper that encrypts/decrypts individual Firestore field values. + * + * Wire format: "enc:v1:{base64(tinkCiphertext)}" + * Plaintext values (no prefix) pass through unchanged so legacy data works. + * + * AAD = coupleId bytes — binds ciphertext to the couple and prevents + * copy-paste of one couple's ciphertext into another couple's document. + */ +@Singleton +class FieldEncryptor @Inject constructor() { + + fun encrypt(plaintext: String, aead: Aead, coupleId: String): String { + val cipher = aead.encrypt( + plaintext.toByteArray(Charsets.UTF_8), + coupleId.toByteArray(Charsets.UTF_8) + ) + return PREFIX + Base64.encodeToString(cipher, Base64.NO_WRAP) + } + + fun encryptNullable(value: String?, aead: Aead, coupleId: String): String? = + value?.let { encrypt(it, aead, coupleId) } + + /** + * Decrypts if the value has the enc:v1: prefix; returns the original string otherwise. + * Returns null when [value] is null or [aead] is null and the value is encrypted. + */ + fun decrypt(value: String?, aead: Aead?, coupleId: String): String? { + if (value == null) return null + if (!value.startsWith(PREFIX)) return value + if (aead == null) return null + return runCatching { + val cipher = Base64.decode(value.removePrefix(PREFIX), Base64.NO_WRAP) + aead.decrypt(cipher, coupleId.toByteArray(Charsets.UTF_8)) + .toString(Charsets.UTF_8) + }.getOrNull() + } + + fun isEncrypted(value: String?): Boolean = value?.startsWith(PREFIX) == true + + companion object { + const val PREFIX = "enc:v1:" + } +} diff --git a/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt b/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt new file mode 100644 index 00000000..be5c8dc9 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/RecoveryKeyManager.kt @@ -0,0 +1,140 @@ +package app.closer.crypto + +import android.util.Base64 +import com.google.crypto.tink.KeysetHandle +import com.google.crypto.tink.aead.AesGcmKeyManager +import com.google.crypto.tink.subtle.AesGcmJce +import org.bouncycastle.crypto.generators.Argon2BytesGenerator +import org.bouncycastle.crypto.params.Argon2Parameters +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Pure crypto helper — no Firestore, no Android dependencies. + * Handles: keyset generation, Argon2id KDF, couple-key wrap/unwrap, phrase generation. + * + * Argon2id params (m=46 MiB, t=3, p=1): ~2-3 s on a mid-range phone. + * Run on Dispatchers.Default to avoid blocking the main thread. + */ +@Singleton +class RecoveryKeyManager @Inject constructor() { + + data class WrappedKey( + val cipherB64: String, + val saltB64: String, + val params: String + ) + + fun generateRecoveryPhrase(): String { + val random = SecureRandom() + val indices = (0 until PHRASE_WORD_COUNT).map { random.nextInt(WORDLIST.size) } + return indices.joinToString(" ") { WORDLIST[it] } + } + + fun newCoupleKeyset(): KeysetHandle = + KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate()) + + /** + * Wraps [keyset] with Argon2id(passphrase, salt) using AES-256-GCM. + * AAD is the fixed constant [WRAP_AAD] so the blob is portable across + * invite → couple transition without re-wrapping. + */ + fun wrap(keyset: KeysetHandle, phrase: String): WrappedKey { + val salt = ByteArray(SALT_BYTES).also { SecureRandom().nextBytes(it) } + val wrapKey = deriveKey(phrase, salt) + val keysetBytes = serializeKeyset(keyset) + val cipherBytes = AesGcmJce(wrapKey).encrypt(keysetBytes, WRAP_AAD) + return WrappedKey( + cipherB64 = Base64.encodeToString(cipherBytes, Base64.NO_WRAP), + saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP), + params = PARAMS_TAG + ) + } + + /** + * Unwraps a [WrappedKey] using [phrase]. Throws [javax.crypto.AEADBadTagException] + * (wrapped as GeneralSecurityException) if the phrase is wrong. + */ + fun unwrap(wrapped: WrappedKey, phrase: String): KeysetHandle { + val salt = Base64.decode(wrapped.saltB64, Base64.NO_WRAP) + val cipher = Base64.decode(wrapped.cipherB64, Base64.NO_WRAP) + val wrapKey = deriveKey(phrase, salt) + val keysetBytes = AesGcmJce(wrapKey).decrypt(cipher, WRAP_AAD) + return deserializeKeyset(keysetBytes) + } + + /** Serialize a KeysetHandle to bytes (cleartext JSON — stored inside EncryptedSharedPreferences). */ + fun serializeKeyset(handle: KeysetHandle): ByteArray { + val baos = java.io.ByteArrayOutputStream() + com.google.crypto.tink.CleartextKeysetHandle.write( + handle, com.google.crypto.tink.JsonKeysetWriter.withOutputStream(baos) + ) + return baos.toByteArray() + } + + fun deserializeKeyset(bytes: ByteArray): KeysetHandle = + com.google.crypto.tink.CleartextKeysetHandle.read( + com.google.crypto.tink.JsonKeysetReader.withBytes(bytes) + ) + + private fun deriveKey(phrase: String, salt: ByteArray): ByteArray { + val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id) + .withSalt(salt) + .withParallelism(ARGON2_PARALLELISM) + .withMemoryAsKB(ARGON2_MEMORY_KB) + .withIterations(ARGON2_ITERATIONS) + .build() + val gen = Argon2BytesGenerator() + gen.init(params) + val out = ByteArray(KEY_BYTES) + gen.generateBytes(phrase.toCharArray(), out, 0, KEY_BYTES) + return out + } + + companion object { + private const val PHRASE_WORD_COUNT = 6 + private const val SALT_BYTES = 16 + private const val KEY_BYTES = 32 + private const val ARGON2_MEMORY_KB = 46 * 1024 + private const val ARGON2_ITERATIONS = 3 + private const val ARGON2_PARALLELISM = 1 + private const val PARAMS_TAG = "argon2id;v=19;m=47104;t=3;p=1" + private val WRAP_AAD = "closer_couple_key".toByteArray(Charsets.UTF_8) + + // 256-word list → 6 words → 48 bits raw entropy; Argon2id makes brute-force infeasible. + val WORDLIST = arrayOf( + "able","acid","acre","aged","aide","also","army","atom", + "baby","back","bake","ball","bank","barn","base","bath", + "bead","beam","bear","beat","belt","best","bill","bird", + "bite","blue","boat","bold","bone","book","boot","bore", + "cage","cake","call","calm","camp","cape","card","care", + "cart","cave","cell","cent","coal","coat","code","coin", + "cold","come","cone","cord","core","corn","cost","crew", + "dame","damp","dare","dark","dart","date","dead","deal", + "dean","deer","dent","dice","disk","dish","dock","done", + "dose","dove","down","draw","drip","drop","drum","dusk", + "each","earn","east","edge","epic","even","exam","exit", + "fact","fail","fair","fall","fame","farm","fast","fate", + "feel","fill","film","find","fire","fish","five","flat", + "flow","foam","food","foot","form","free","fuel","full", + "gain","game","gate","gear","give","glad","glow","goal", + "gold","good","gray","grow","gulf","gust","half","hall", + "halt","hand","hard","harm","head","heat","help","high", + "hill","hint","hold","hole","home","hook","hope","hour", + "huge","hull","hunt","hurt","idea","idle","inch","iris", + "jade","jail","jest","join","jury","just","keen","kept", + "kind","king","knee","knew","know","land","lane","last", + "late","lead","leaf","lean","left","less","life","like", + "line","lion","list","live","load","lock","long","look", + "loop","lord","lost","loud","love","made","mail","main", + "make","mark","maze","mean","meet","mild","mind","mine", + "miss","mode","more","most","move","much","must","name", + "near","need","nest","news","next","nice","nine","node", + "noon","norm","note","once","only","open","over","pack", + "page","pain","pair","pale","park","part","past","path", + "peak","pick","pine","plan","play","plus","poor","port", + "post","pull","pure","race","rank","rate","read","real" + ) + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt index 48cf1c0f..5e690a26 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt @@ -1,8 +1,11 @@ package app.closer.data.remote +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.FieldEncryptor import app.closer.domain.model.LocalAnswer import com.google.firebase.firestore.FirebaseFirestore import kotlinx.coroutines.suspendCancellableCoroutine +import org.json.JSONArray import java.text.SimpleDateFormat import java.util.Calendar import java.util.Locale @@ -21,7 +24,11 @@ import kotlin.coroutines.resumeWithException * both partners may read either answer. Firestore rules enforce this. */ @Singleton -class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFirestore) { +class FirestoreAnswerDataSource @Inject constructor( + private val db: FirebaseFirestore, + private val encryptionManager: CoupleEncryptionManager, + private val fieldEncryptor: FieldEncryptor +) { private fun dailyQuestionRef(coupleId: String, date: String) = db.collection(FirestoreCollections.COUPLES) @@ -43,13 +50,18 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire answer: LocalAnswer ): Unit = suspendCancellableCoroutine { cont -> val date = todayUtcString() + val aead = encryptionManager.aeadFor(coupleId) val data = mapOf( "userId" to userId, "questionId" to questionId, "answerType" to answer.answerType, - "writtenText" to answer.writtenText, - "selectedOptionIds" to answer.selectedOptionIds, - "scaleValue" to answer.scaleValue, + "writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText, + "selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty()) + listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId)) + else answer.selectedOptionIds, + "scaleValue" to if (aead != null && answer.scaleValue != null) + fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId) + else answer.scaleValue, "createdAt" to answer.createdAt, "updatedAt" to answer.updatedAt, "isRevealed" to answer.isRevealed @@ -76,7 +88,8 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire cont.resume(null) return@addOnSuccessListener } - cont.resume(snap.toLocalAnswer()) + val aead = encryptionManager.aeadFor(coupleId) + cont.resume(snap.toLocalAnswer(aead, coupleId)) } .addOnFailureListener { cont.resumeWithException(it) } } @@ -120,17 +133,39 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire } @Suppress("UNCHECKED_CAST") - private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer { - val ids = get("selectedOptionIds") as? List ?: emptyList() + private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer( + aead: com.google.crypto.tink.Aead?, + coupleId: String + ): LocalAnswer { + val rawIds = get("selectedOptionIds") as? List ?: emptyList() + // selectedOptionIds is stored as a single encrypted JSON blob OR a plaintext list + val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) { + val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId) + if (decrypted != null) runCatching { + val arr = org.json.JSONArray(decrypted) + (0 until arr.length()).map { arr.getString(it) } + }.getOrDefault(emptyList()) else emptyList() + } else rawIds + + val rawScale = get("scaleValue") + val scaleValue: Int? = when { + rawScale == null -> null + rawScale is String && fieldEncryptor.isEncrypted(rawScale) -> + fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull() + rawScale is Long -> rawScale.toInt() + rawScale is Int -> rawScale + else -> null + } + return LocalAnswer( questionId = getString("questionId") ?: "", questionText = "", category = "", answerType = getString("answerType") ?: "written", - writtenText = getString("writtenText"), - selectedOptionIds = ids, + writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId), + selectedOptionIds = selectedOptionIds, selectedOptionTexts = emptyList(), - scaleValue = if (getLong("scaleValue") == null && get("scaleValue") == null) null else (getLong("scaleValue")?.toInt() ?: get("scaleValue") as? Int), + scaleValue = scaleValue, createdAt = getLong("createdAt") ?: System.currentTimeMillis(), updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(), isRevealed = getBoolean("isRevealed") ?: false diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt index fef6fb83..88805377 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCoupleDataSource.kt @@ -1,11 +1,11 @@ package app.closer.data.remote +import app.closer.crypto.RecoveryKeyManager import app.closer.domain.model.Couple import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions import kotlinx.coroutines.suspendCancellableCoroutine -import java.util.UUID import javax.inject.Inject import javax.inject.Singleton import kotlin.coroutines.resume @@ -16,10 +16,16 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire private fun coupleRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId) private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid) - suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): String { - val coupleId = UUID.randomUUID().toString() + /** [coupleId] is pre-generated by the repository so the keyset can be stored locally first. */ + suspend fun createCouple( + coupleId: String, + inviterUserId: String, + acceptorUserId: String, + inviteCode: String, + wrappedKey: RecoveryKeyManager.WrappedKey? + ): String { val now = System.currentTimeMillis() - createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now) + createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now, wrappedKey) updateUserCoupleId(inviterUserId, coupleId) updateUserCoupleId(acceptorUserId, coupleId) return coupleId @@ -30,21 +36,41 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire inviterUserId: String, acceptorUserId: String, inviteCode: String, - now: Long + now: Long, + wrappedKey: RecoveryKeyManager.WrappedKey? ): Unit = suspendCancellableCoroutine { cont -> - coupleRef(coupleId).set( - mapOf( - "id" to coupleId, - "userIds" to listOf(inviterUserId, acceptorUserId), - "inviteCode" to inviteCode, - "createdAt" to now, - "streakCount" to 0 - ) + val data = mutableMapOf( + "id" to coupleId, + "userIds" to listOf(inviterUserId, acceptorUserId), + "inviteCode" to inviteCode, + "createdAt" to now, + "streakCount" to 0 ) + if (wrappedKey != null) { + data["encryptionVersion"] = 1 + data["wrappedCoupleKey"] = wrappedKey.cipherB64 + data["kdfSalt"] = wrappedKey.saltB64 + data["kdfParams"] = wrappedKey.params + } + coupleRef(coupleId).set(data) .addOnSuccessListener { cont.resume(Unit) } .addOnFailureListener { cont.resumeWithException(it) } } + suspend fun updateWrappedKey(coupleId: String, wrappedKey: RecoveryKeyManager.WrappedKey): Unit = + suspendCancellableCoroutine { cont -> + coupleRef(coupleId).set( + mapOf( + "wrappedCoupleKey" to wrappedKey.cipherB64, + "kdfSalt" to wrappedKey.saltB64, + "kdfParams" to wrappedKey.params + ), + SetOptions.merge() + ) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } + private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit = suspendCancellableCoroutine { cont -> userRef(uid).set( @@ -122,7 +148,11 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire currentQuestionId = getString("currentQuestionId"), streakCount = (getLong("streakCount") ?: 0L).toInt(), lastAnsweredAt = getLong("lastAnsweredAt"), - activePackId = getString("activePackId") + activePackId = getString("activePackId"), + encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(), + wrappedCoupleKey = getString("wrappedCoupleKey"), + kdfSalt = getString("kdfSalt"), + kdfParams = getString("kdfParams") ) companion object { diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt index 3243a0ee..2e303de6 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreDesireSyncDataSource.kt @@ -1,11 +1,14 @@ package app.closer.data.remote +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.FieldEncryptor import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.tasks.await +import org.json.JSONArray import javax.inject.Inject import javax.inject.Singleton @@ -23,7 +26,9 @@ data class DesireSyncAnswers( */ @Singleton class FirestoreDesireSyncDataSource @Inject constructor( - private val db: FirebaseFirestore + private val db: FirebaseFirestore, + private val encryptionManager: CoupleEncryptionManager, + private val fieldEncryptor: FieldEncryptor ) { private fun doc(coupleId: String, sessionId: String) = db.collection(FirestoreCollections.COUPLES) @@ -38,8 +43,12 @@ class FirestoreDesireSyncDataSource @Inject constructor( userId: String, optionIds: List ) { + val aead = encryptionManager.aeadFor(coupleId) + val value = if (aead != null) + listOf(fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId)) + else optionIds doc(coupleId, sessionId) - .set(mapOf("answers" to mapOf(userId to optionIds)), SetOptions.merge()) + .set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge()) .await() } @@ -47,7 +56,8 @@ class FirestoreDesireSyncDataSource @Inject constructor( suspend fun getAnswers(coupleId: String, sessionId: String): DesireSyncAnswers? = runCatching { val snap = doc(coupleId, sessionId).get().await() - DesireSyncAnswers(parseAnswers(snap.get("answers"))) + val aead = encryptionManager.aeadFor(coupleId) + DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)) }.getOrNull() /** Live view of both partners' picks; emits whenever either side submits. */ @@ -55,16 +65,30 @@ class FirestoreDesireSyncDataSource @Inject constructor( callbackFlow { val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> if (err != null || snap == null) return@addSnapshotListener - trySend(DesireSyncAnswers(parseAnswers(snap.get("answers")))) + val aead = encryptionManager.aeadFor(coupleId) + trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId))) } awaitClose { reg.remove() } } - private fun parseAnswers(raw: Any?): Map> { + private fun parseAnswers( + raw: Any?, + aead: com.google.crypto.tink.Aead?, + coupleId: String + ): Map> { @Suppress("UNCHECKED_CAST") val map = raw as? Map ?: return emptyMap() return map.mapNotNull { (uid, value) -> - (value as? List<*>)?.filterIsInstance()?.let { uid to it } + val list = (value as? List<*>)?.filterIsInstance() ?: return@mapNotNull null + // Encrypted as a single blob; plaintext as a real list + val decrypted = if (list.size == 1 && fieldEncryptor.isEncrypted(list[0])) { + val json = fieldEncryptor.decrypt(list[0], aead, coupleId) ?: return@mapNotNull null + runCatching { + val arr = JSONArray(json) + (0 until arr.length()).map { arr.getString(it) } + }.getOrNull() ?: return@mapNotNull null + } else list + uid to decrypted }.toMap() } } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt index 3a64a6ee..ff934a40 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreHowWellDataSource.kt @@ -1,11 +1,15 @@ package app.closer.data.remote +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.FieldEncryptor import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.tasks.await +import org.json.JSONArray +import org.json.JSONObject import javax.inject.Inject import javax.inject.Singleton @@ -27,7 +31,9 @@ data class HowWellAnswers( */ @Singleton class FirestoreHowWellDataSource @Inject constructor( - private val db: FirebaseFirestore + private val db: FirebaseFirestore, + private val encryptionManager: CoupleEncryptionManager, + private val fieldEncryptor: FieldEncryptor ) { private fun doc(coupleId: String, sessionId: String) = db.collection(FirestoreCollections.COUPLES) @@ -42,16 +48,28 @@ class FirestoreHowWellDataSource @Inject constructor( userId: String, answers: List ) { - val payload = answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) } + val aead = encryptionManager.aeadFor(coupleId) + val value: Any = if (aead != null) { + val json = JSONArray(answers.map { + JSONObject().apply { + put("optionId", it.optionId ?: JSONObject.NULL) + put("scale", it.scale ?: JSONObject.NULL) + } + }.toString()) + listOf(fieldEncryptor.encrypt(json.toString(), aead, coupleId)) + } else { + answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) } + } doc(coupleId, sessionId) - .set(mapOf("answers" to mapOf(userId to payload)), SetOptions.merge()) + .set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge()) .await() } /** One-shot read — used to detect whether this user has already answered. */ suspend fun getAnswers(coupleId: String, sessionId: String): HowWellAnswers? = runCatching { - HowWellAnswers(parseAnswers(doc(coupleId, sessionId).get().await().get("answers"))) + val aead = encryptionManager.aeadFor(coupleId) + HowWellAnswers(parseAnswers(doc(coupleId, sessionId).get().await().get("answers"), aead, coupleId)) }.getOrNull() /** Live view of both partners' answers; emits whenever either side submits. */ @@ -59,16 +77,36 @@ class FirestoreHowWellDataSource @Inject constructor( callbackFlow { val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> if (err != null || snap == null) return@addSnapshotListener - trySend(HowWellAnswers(parseAnswers(snap.get("answers")))) + val aead = encryptionManager.aeadFor(coupleId) + trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId))) } awaitClose { reg.remove() } } - private fun parseAnswers(raw: Any?): Map> { + private fun parseAnswers( + raw: Any?, + aead: com.google.crypto.tink.Aead?, + coupleId: String + ): Map> { @Suppress("UNCHECKED_CAST") val map = raw as? Map ?: return emptyMap() return map.mapNotNull { (uid, value) -> - (value as? List<*>)?.let { list -> + val list = (value as? List<*>) ?: return@mapNotNull null + // Encrypted: single-element list with JSON blob + if (list.size == 1 && list[0] is String && fieldEncryptor.isEncrypted(list[0] as String)) { + val json = fieldEncryptor.decrypt(list[0] as String, aead, coupleId) ?: return@mapNotNull null + val answers = runCatching { + val arr = JSONArray(json) + (0 until arr.length()).map { i -> + val obj = arr.getJSONObject(i) + HowWellRawAnswer( + optionId = if (obj.isNull("optionId")) null else obj.getString("optionId"), + scale = if (obj.isNull("scale")) null else obj.getInt("scale") + ) + } + }.getOrNull() ?: return@mapNotNull null + uid to answers + } else { uid to list.mapNotNull { item -> (item as? Map<*, *>)?.let { HowWellRawAnswer( diff --git a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt index feb0715b..d80a35e3 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt @@ -1,5 +1,6 @@ package app.closer.data.remote +import app.closer.crypto.RecoveryKeyManager import app.closer.domain.model.Invite import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions @@ -18,21 +19,27 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire .map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] } .joinToString("") - suspend fun createInvite(code: String, inviterUserId: String): Unit = - suspendCancellableCoroutine { cont -> - val now = System.currentTimeMillis() - inviteRef(code).set( - mapOf( - "code" to code, - "inviterUserId" to inviterUserId, - "status" to "pending", - "createdAt" to now, - "expiresAt" to now + 24 * 60 * 60 * 1000L - ) + suspend fun createInvite( + code: String, + inviterUserId: String, + wrappedKey: RecoveryKeyManager.WrappedKey + ): Unit = suspendCancellableCoroutine { cont -> + val now = System.currentTimeMillis() + inviteRef(code).set( + mapOf( + "code" to code, + "inviterUserId" to inviterUserId, + "status" to "pending", + "createdAt" to now, + "expiresAt" to now + 24 * 60 * 60 * 1000L, + "wrappedCoupleKey" to wrappedKey.cipherB64, + "kdfSalt" to wrappedKey.saltB64, + "kdfParams" to wrappedKey.params ) - .addOnSuccessListener { cont.resume(Unit) } - .addOnFailureListener { cont.resumeWithException(it) } - } + ) + .addOnSuccessListener { cont.resume(Unit) } + .addOnFailureListener { cont.resumeWithException(it) } + } suspend fun getInviteByCode(code: String): Invite? = suspendCancellableCoroutine { cont -> @@ -50,7 +57,10 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire createdAt = snap.getLong("createdAt") ?: 0L, expiresAt = snap.getLong("expiresAt") ?: 0L, acceptedAt = snap.getLong("acceptedAt"), - acceptedByUserId = snap.getString("acceptedByUserId") + acceptedByUserId = snap.getString("acceptedByUserId"), + wrappedCoupleKey = snap.getString("wrappedCoupleKey"), + kdfSalt = snap.getString("kdfSalt"), + kdfParams = snap.getString("kdfParams") ) ) } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt index 0e74c2b4..28dcb0d2 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt @@ -1,10 +1,13 @@ package app.closer.data.remote +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.FieldEncryptor import app.closer.domain.model.QuestionAnswer import app.closer.domain.model.QuestionMessage import app.closer.domain.model.QuestionReaction import app.closer.domain.model.QuestionThread import app.closer.domain.model.QuestionThreadStatus +import org.json.JSONArray import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FirebaseFirestore @@ -19,7 +22,11 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @Singleton -class FirestoreQuestionThreadDataSource @Inject constructor(private val db: FirebaseFirestore) { +class FirestoreQuestionThreadDataSource @Inject constructor( + private val db: FirebaseFirestore, + private val encryptionManager: CoupleEncryptionManager, + private val fieldEncryptor: FieldEncryptor +) { private fun threadsRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId) @@ -71,6 +78,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) { val now = FieldValue.serverTimestamp() + val aead = encryptionManager.aeadFor(coupleId) threadsRef(coupleId) .document(threadId) .collection(FirestoreCollections.QuestionThreads.ANSWERS) @@ -80,9 +88,13 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire "userId" to answer.userId, "questionId" to answer.questionId, "answerType" to answer.answerType, - "writtenText" to answer.writtenText, - "selectedOptionIds" to answer.selectedOptionIds, - "scaleValue" to answer.scaleValue, + "writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText, + "selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty()) + listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId)) + else answer.selectedOptionIds, + "scaleValue" to if (aead != null && answer.scaleValue != null) + fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId) + else answer.scaleValue, "createdAt" to now, "updatedAt" to now ) @@ -103,7 +115,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire .collection(FirestoreCollections.QuestionThreads.ANSWERS) .addSnapshotListener { snap, err -> if (err != null || snap == null) return@addSnapshotListener - trySend(snap.documents.mapNotNull { it.toQuestionAnswer() }) + val aead = encryptionManager.aeadFor(coupleId) + trySend(snap.documents.mapNotNull { it.toQuestionAnswer(aead, coupleId) }) } awaitClose { listener.remove() } } @@ -111,13 +124,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire // ─── Messages ──────────────────────────────────────────────────────────────── suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) { + val aead = encryptionManager.aeadFor(coupleId) threadsRef(coupleId) .document(threadId) .collection(FirestoreCollections.QuestionThreads.MESSAGES) .add( mapOf( "authorUserId" to message.userId, - "text" to message.text, + "text" to if (aead != null) fieldEncryptor.encrypt(message.text, aead, coupleId) else message.text, "createdAt" to FieldValue.serverTimestamp() ) ).refAwait() @@ -130,7 +144,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire .orderBy("createdAt", Query.Direction.ASCENDING) .addSnapshotListener { snap, err -> if (err != null || snap == null) return@addSnapshotListener - trySend(snap.documents.mapNotNull { it.toQuestionMessage() }) + val aead = encryptionManager.aeadFor(coupleId) + trySend(snap.documents.mapNotNull { it.toQuestionMessage(aead, coupleId) }) } awaitClose { listener.remove() } } @@ -204,26 +219,51 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire ) @Suppress("UNCHECKED_CAST") - private fun DocumentSnapshot.toQuestionAnswer(): QuestionAnswer? { + private fun DocumentSnapshot.toQuestionAnswer( + aead: com.google.crypto.tink.Aead?, + coupleId: String + ): QuestionAnswer? { val userId = getString("userId") ?: return null + val rawIds = (get("selectedOptionIds") as? List) ?: emptyList() + val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) { + val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId) + if (decrypted != null) runCatching { + val arr = org.json.JSONArray(decrypted) + (0 until arr.length()).map { arr.getString(it) } + }.getOrDefault(emptyList()) else emptyList() + } else rawIds + + val rawScale = get("scaleValue") + val scaleValue: Int? = when { + rawScale == null -> null + rawScale is String && fieldEncryptor.isEncrypted(rawScale) -> + fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull() + rawScale is Long -> rawScale.toInt() + rawScale is Int -> rawScale + else -> null + } + return QuestionAnswer( userId = userId, questionId = getString("questionId") ?: "", answerType = getString("answerType") ?: "written", - writtenText = getString("writtenText"), - selectedOptionIds = (get("selectedOptionIds") as? List) ?: emptyList(), - scaleValue = getLong("scaleValue")?.toInt(), + writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId), + selectedOptionIds = selectedOptionIds, + scaleValue = scaleValue, createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L, updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L ) } - private fun DocumentSnapshot.toQuestionMessage(): QuestionMessage? { + private fun DocumentSnapshot.toQuestionMessage( + aead: com.google.crypto.tink.Aead?, + coupleId: String + ): QuestionMessage? { val userId = getString("authorUserId") ?: return null return QuestionMessage( id = id, userId = userId, - text = getString("text") ?: "", + text = fieldEncryptor.decrypt(getString("text"), aead, coupleId) ?: "", createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L ) } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt index 6191d7e2..16c75567 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreWheelAnswerDataSource.kt @@ -1,5 +1,7 @@ package app.closer.data.remote +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.FieldEncryptor import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions @@ -32,7 +34,9 @@ data class WheelRevealDoc( */ @Singleton class FirestoreWheelAnswerDataSource @Inject constructor( - private val db: FirebaseFirestore + private val db: FirebaseFirestore, + private val encryptionManager: CoupleEncryptionManager, + private val fieldEncryptor: FieldEncryptor ) { private fun doc(coupleId: String, sessionId: String) = db.collection(FirestoreCollections.COUPLES) @@ -49,11 +53,17 @@ class FirestoreWheelAnswerDataSource @Inject constructor( questions: List, answers: List ) { + val aead = encryptionManager.aeadFor(coupleId) val data = mapOf( "categoryName" to categoryName, "questions" to questions.map { mapOf("id" to it.id, "text" to it.text) }, "answers" to mapOf( - userId to answers.map { mapOf("questionId" to it.questionId, "display" to it.display) } + userId to answers.map { + mapOf( + "questionId" to it.questionId, + "display" to if (aead != null) fieldEncryptor.encrypt(it.display, aead, coupleId) else it.display + ) + } ) ) doc(coupleId, sessionId).set(data, SetOptions.merge()).await() @@ -61,18 +71,19 @@ class FirestoreWheelAnswerDataSource @Inject constructor( /** One-shot read — used to detect whether this user has already answered. */ suspend fun getDoc(coupleId: String, sessionId: String): WheelRevealDoc? = - runCatching { parse(doc(coupleId, sessionId).get().await()) }.getOrNull() + runCatching { parse(doc(coupleId, sessionId).get().await(), coupleId) }.getOrNull() /** Live view of both partners' answers; emits whenever either side submits. */ fun observe(coupleId: String, sessionId: String): Flow = callbackFlow { val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> if (err != null || snap == null) return@addSnapshotListener - trySend(parse(snap)) + trySend(parse(snap, coupleId)) } awaitClose { reg.remove() } } - private fun parse(snap: DocumentSnapshot): WheelRevealDoc { + private fun parse(snap: DocumentSnapshot, coupleId: String): WheelRevealDoc { + val aead = encryptionManager.aeadFor(coupleId) val questions = (snap.get("questions") as? List<*>).orEmpty().mapNotNull { item -> (item as? Map<*, *>)?.let { WheelQuestionRef( @@ -86,9 +97,10 @@ class FirestoreWheelAnswerDataSource @Inject constructor( val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) -> (value as? List<*>).orEmpty().mapNotNull { item -> (item as? Map<*, *>)?.let { + val rawDisplay = it["display"] as? String ?: "" WheelAnswerEntry( questionId = it["questionId"] as? String ?: "", - display = it["display"] as? String ?: "" + display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay ) } } diff --git a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt index 1baba4fc..cac18e77 100644 --- a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt @@ -1,17 +1,23 @@ package app.closer.data.repository import app.closer.core.crash.CrashReporter +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.RecoveryKeyManager import app.closer.data.remote.FirestoreCoupleDataSource +import app.closer.data.remote.FirestoreInviteDataSource import app.closer.data.remote.FirestoreUserDataSource import app.closer.domain.model.Couple import app.closer.domain.repository.CoupleRepository +import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @Singleton class CoupleRepositoryImpl @Inject constructor( private val coupleDataSource: FirestoreCoupleDataSource, + private val inviteDataSource: FirestoreInviteDataSource, private val userDataSource: FirestoreUserDataSource, + private val encryptionManager: CoupleEncryptionManager, private val crashReporter: CrashReporter ) : CoupleRepository { @@ -24,8 +30,30 @@ class CoupleRepositoryImpl @Inject constructor( .getOrNull() } - override suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result = runCatching { - coupleDataSource.createCouple(inviterUserId, acceptorUserId, inviteCode) + override suspend fun createCouple( + inviterUserId: String, + acceptorUserId: String, + inviteCode: String, + recoveryPhrase: String + ): Result = runCatching { + val coupleId = UUID.randomUUID().toString() + + // Load wrapped key from invite to unwrap with the acceptor's phrase + val invite = inviteDataSource.getInviteByCode(inviteCode) + val wrappedKey = if (invite?.wrappedCoupleKey != null) { + RecoveryKeyManager.WrappedKey( + cipherB64 = invite.wrappedCoupleKey, + saltB64 = invite.kdfSalt ?: error("Missing kdfSalt on invite"), + params = invite.kdfParams ?: error("Missing kdfParams on invite") + ) + } else null + + if (wrappedKey != null) { + encryptionManager.unwrapAndStore(coupleId, wrappedKey, recoveryPhrase) + .getOrElse { throw it } + } + + coupleDataSource.createCouple(coupleId, inviterUserId, acceptorUserId, inviteCode, wrappedKey) } override suspend fun updateStreak(coupleId: String): Result = runCatching { @@ -33,6 +61,13 @@ class CoupleRepositoryImpl @Inject constructor( } override suspend fun leaveCouple(userId: String): Result = runCatching { + val coupleId = userDataSource.getUser(userId)?.coupleId coupleDataSource.leaveCouple(userId) + if (coupleId != null) encryptionManager.deleteKeyset(coupleId) + } + + override suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result = runCatching { + val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it } + coupleDataSource.updateWrappedKey(coupleId, newWrapped) } } diff --git a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt index 14c82825..f9ff99de 100644 --- a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt @@ -1,20 +1,24 @@ package app.closer.data.repository +import app.closer.crypto.CoupleEncryptionManager import app.closer.data.remote.FirestoreInviteDataSource import app.closer.domain.model.Invite +import app.closer.domain.repository.CreateInviteResult import app.closer.domain.repository.InviteRepository import javax.inject.Inject import javax.inject.Singleton @Singleton class InviteRepositoryImpl @Inject constructor( - private val dataSource: FirestoreInviteDataSource + private val dataSource: FirestoreInviteDataSource, + private val encryptionManager: CoupleEncryptionManager ) : InviteRepository { - override suspend fun createInvite(inviterUserId: String): Result = runCatching { + override suspend fun createInvite(inviterUserId: String): Result = runCatching { val code = dataSource.generateCode() - dataSource.createInvite(code, inviterUserId) - code + val setup = encryptionManager.setupForNewCouple(code) + dataSource.createInvite(code, inviterUserId, setup.wrapped) + CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase) } override suspend fun getInviteByCode(code: String): Result = runCatching { diff --git a/app/src/main/java/app/closer/domain/model/Couple.kt b/app/src/main/java/app/closer/domain/model/Couple.kt index 49af7d94..404a7d98 100644 --- a/app/src/main/java/app/closer/domain/model/Couple.kt +++ b/app/src/main/java/app/closer/domain/model/Couple.kt @@ -8,5 +8,10 @@ data class Couple( val currentQuestionId: String? = null, val streakCount: Int = 0, val lastAnsweredAt: Long? = null, - val activePackId: String? = null + val activePackId: String? = null, + // E2EE: version 0 = plaintext, version 1 = Tink AES256-GCM + Argon2id recovery + val encryptionVersion: Int = 0, + val wrappedCoupleKey: String? = null, + val kdfSalt: String? = null, + val kdfParams: String? = null ) diff --git a/app/src/main/java/app/closer/domain/model/Invite.kt b/app/src/main/java/app/closer/domain/model/Invite.kt index dd4af3cc..920de1e7 100644 --- a/app/src/main/java/app/closer/domain/model/Invite.kt +++ b/app/src/main/java/app/closer/domain/model/Invite.kt @@ -10,5 +10,9 @@ data class Invite( val createdAt: Long = System.currentTimeMillis(), val expiresAt: Long = System.currentTimeMillis() + 24 * 60 * 60 * 1000L, val acceptedAt: Long? = null, - val acceptedByUserId: String? = null + val acceptedByUserId: String? = null, + // E2EE: wrapped couple keyset fields — null on legacy/unencrypted invites + val wrappedCoupleKey: String? = null, + val kdfSalt: String? = null, + val kdfParams: String? = null ) diff --git a/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt b/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt index 1e55915f..1b4e0bbf 100644 --- a/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/CoupleRepository.kt @@ -4,7 +4,8 @@ import app.closer.domain.model.Couple interface CoupleRepository { suspend fun getCoupleForUser(userId: String): Couple? - suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result + suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String, recoveryPhrase: String): Result suspend fun updateStreak(coupleId: String): Result suspend fun leaveCouple(userId: String): Result + suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result } diff --git a/app/src/main/java/app/closer/domain/repository/InviteRepository.kt b/app/src/main/java/app/closer/domain/repository/InviteRepository.kt index 90a65e5d..783b4b16 100644 --- a/app/src/main/java/app/closer/domain/repository/InviteRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/InviteRepository.kt @@ -2,8 +2,10 @@ package app.closer.domain.repository import app.closer.domain.model.Invite +data class CreateInviteResult(val code: String, val recoveryPhrase: String) + interface InviteRepository { - suspend fun createInvite(inviterUserId: String): Result + suspend fun createInvite(inviterUserId: String): Result suspend fun getInviteByCode(code: String): Result suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result } diff --git a/app/src/main/java/app/closer/ui/home/HomeScreen.kt b/app/src/main/java/app/closer/ui/home/HomeScreen.kt index c2ebaa50..49be6183 100644 --- a/app/src/main/java/app/closer/ui/home/HomeScreen.kt +++ b/app/src/main/java/app/closer/ui/home/HomeScreen.kt @@ -77,6 +77,12 @@ fun HomeScreen( } } + LaunchedEffect(state.needsRecovery) { + if (state.needsRecovery) { + onNavigate(app.closer.core.navigation.AppRoute.RECOVERY) + } + } + HomeContent( state = state, snackbarHostState = snackbarHostState, diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index cb81987f..e7933b58 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -3,6 +3,8 @@ package app.closer.ui.home import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.EncryptionStatus import app.closer.domain.model.LocalAnswer import app.closer.domain.model.Question import app.closer.domain.model.QuestionCategory @@ -73,7 +75,8 @@ data class HomeUiState( val isPaired: Boolean = false, val primaryAction: HomeAction? = null, val secondaryActions: List = emptyList(), - val partnerLeftEvent: Boolean = false + val partnerLeftEvent: Boolean = false, + val needsRecovery: Boolean = false ) @HiltViewModel @@ -83,6 +86,7 @@ class HomeViewModel @Inject constructor( private val authRepository: AuthRepository, private val coupleRepository: CoupleRepository, private val userRepository: UserRepository, + private val encryptionManager: CoupleEncryptionManager, private val db: FirebaseFirestore ) : ViewModel() { @@ -123,6 +127,8 @@ class HomeViewModel @Inject constructor( .onFailure { Log.w(TAG, "Could not load partner display name", it) } .getOrNull() } + val needsRecovery = couple != null && + encryptionManager.checkStatus(couple) == EncryptionStatus.NEEDS_RECOVERY _uiState.update { it.copy( isLoading = false, @@ -131,7 +137,8 @@ class HomeViewModel @Inject constructor( partnerName = partnerName, streakCount = couple?.streakCount ?: 0, isPaired = couple != null, - partnerLeftEvent = false + partnerLeftEvent = false, + needsRecovery = needsRecovery ).withHomeActions() } } catch (e: Exception) { @@ -182,6 +189,12 @@ class HomeViewModel @Inject constructor( _uiState.update { it.copy(partnerLeftEvent = false) } } + /** Called after the recovery flow completes so the banner goes away. */ + fun onRecoveryCompleted() { + _uiState.update { it.copy(needsRecovery = false) } + loadHome() + } + private fun observeAnswers() { viewModelScope.launch { localAnswerRepository.observeAnswers().collect { answers -> diff --git a/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt b/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt index 389d3a31..b23a2b1d 100644 --- a/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/CreateInviteScreen.kt @@ -23,6 +23,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Share +import androidx.compose.foundation.border +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card @@ -233,6 +235,69 @@ fun CreateInviteScreen( textAlign = TextAlign.Center ) + // Recovery phrase — shown once; user must write it down + state.recoveryPhrase?.let { phrase -> + Spacer(Modifier.height(28.dp)) + + Card( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, SettingsPrimaryDeep.copy(alpha = 0.3f), RoundedCornerShape(16.dp)), + colors = CardDefaults.cardColors(containerColor = SettingsSoft), + elevation = CardDefaults.cardElevation(0.dp) + ) { + Column( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Filled.Lock, + contentDescription = null, + tint = SettingsPrimaryDeep, + modifier = Modifier.size(16.dp) + ) + Text( + "Recovery phrase", + style = MaterialTheme.typography.labelMedium, + color = SettingsPrimaryDeep, + fontWeight = FontWeight.SemiBold + ) + } + Text( + phrase, + style = MaterialTheme.typography.bodyMedium, + color = SettingsInk, + fontWeight = FontWeight.Medium + ) + Text( + "Write this down and share it with your partner. You'll both need it to access your answers on a new phone.", + style = MaterialTheme.typography.bodySmall, + color = SettingsMuted + ) + Button( + onClick = { + clipboard.setText(AnnotatedString(phrase)) + scope.launch { snackbar.showSnackbar("Recovery phrase copied!") } + }, + modifier = Modifier.fillMaxWidth().height(44.dp), + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.buttonColors( + containerColor = SettingsPrimary.copy(alpha = 0.12f), + contentColor = SettingsPrimaryDeep + ) + ) { + Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("Copy phrase", style = MaterialTheme.typography.labelMedium) + } + } + } + } + Spacer(Modifier.height(28.dp)) TextButton( diff --git a/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt b/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt index d9c620c9..4554b171 100644 --- a/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/CreateInviteViewModel.kt @@ -17,6 +17,7 @@ import javax.inject.Inject data class CreateInviteUiState( val isLoading: Boolean = true, val inviteCode: String? = null, + val recoveryPhrase: String? = null, val error: String? = null, val navigateTo: String? = null ) @@ -43,8 +44,8 @@ class CreateInviteViewModel @Inject constructor( return@launch } inviteRepository.createInvite(userId) - .onSuccess { code -> - _uiState.update { it.copy(isLoading = false, inviteCode = code) } + .onSuccess { result -> + _uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) } } .onFailure { e -> _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't create invite. Please try again.") } diff --git a/app/src/main/java/app/closer/ui/pairing/InviteConfirmScreen.kt b/app/src/main/java/app/closer/ui/pairing/InviteConfirmScreen.kt index bf2090a5..1045e960 100644 --- a/app/src/main/java/app/closer/ui/pairing/InviteConfirmScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/InviteConfirmScreen.kt @@ -25,6 +25,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -41,6 +43,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -142,7 +146,39 @@ fun InviteConfirmScreen( color = SettingsMuted ) - Spacer(Modifier.height(32.dp)) + Spacer(Modifier.height(24.dp)) + + // Recovery phrase input — only shown for encrypted invites + if (state.isEncryptedInvite) { + OutlinedTextField( + value = state.recoveryPhrase, + onValueChange = viewModel::onPhraseChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Recovery phrase") }, + placeholder = { Text("word word word word word word") }, + supportingText = { + Text( + "Your partner sees this when they create the invite.", + style = MaterialTheme.typography.bodySmall, + color = SettingsMuted + ) + }, + singleLine = false, + minLines = 2, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = SettingsPrimaryDeep, + unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f), + focusedLabelColor = SettingsPrimaryDeep, + unfocusedLabelColor = SettingsMuted, + cursorColor = SettingsPrimaryDeep + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + Spacer(Modifier.height(16.dp)) + } else { + Spacer(Modifier.height(8.dp)) + } Button( onClick = viewModel::confirmPairing, diff --git a/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt b/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt index 9d565fe3..30bc1b36 100644 --- a/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt @@ -21,9 +21,11 @@ import javax.inject.Inject data class InviteConfirmUiState( val isLoading: Boolean = true, val inviterName: String? = null, + val recoveryPhrase: String = "", val error: String? = null, val navigateTo: String? = null, - val isConfirming: Boolean = false + val isConfirming: Boolean = false, + val isEncryptedInvite: Boolean = false ) @HiltViewModel @@ -51,7 +53,13 @@ class InviteConfirmViewModel @Inject constructor( .onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) } .getOrNull() } - _uiState.update { it.copy(isLoading = false, inviterName = inviterName ?: "your partner") } + _uiState.update { + it.copy( + isLoading = false, + inviterName = inviterName ?: "your partner", + isEncryptedInvite = invite?.wrappedCoupleKey != null + ) + } } .onFailure { _uiState.update { it.copy(isLoading = false, error = "Couldn't load invite details. Please go back and try again.") } @@ -59,6 +67,8 @@ class InviteConfirmViewModel @Inject constructor( } } + fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) } + fun confirmPairing() { val acceptorId = authRepository.currentUserId ?: run { _uiState.update { it.copy(error = "Not signed in.") } @@ -68,15 +78,26 @@ class InviteConfirmViewModel @Inject constructor( _uiState.update { it.copy(error = "Invite not loaded yet.") } return } + val phrase = _uiState.value.recoveryPhrase.trim() + if (invite.wrappedCoupleKey != null && phrase.isBlank()) { + _uiState.update { it.copy(error = "Enter the recovery phrase your partner shared with you.") } + return + } _uiState.update { it.copy(isConfirming = true, error = null) } viewModelScope.launch { - coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode) + coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode, phrase) .onSuccess { coupleId -> inviteRepository.markAccepted(inviteCode, acceptorId, coupleId) _uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) } } .onFailure { e -> - _uiState.update { it.copy(isConfirming = false, error = e.message ?: "Couldn't complete pairing. Please try again.") } + val msg = when { + e.message?.contains("AEADBadTag", ignoreCase = true) == true || + e.message?.contains("decryption", ignoreCase = true) == true -> + "That phrase doesn't match. Ask your partner to recheck it." + else -> e.message ?: "Couldn't complete pairing. Please try again." + } + _uiState.update { it.copy(isConfirming = false, error = msg) } } } } diff --git a/app/src/main/java/app/closer/ui/pairing/RecoveryScreen.kt b/app/src/main/java/app/closer/ui/pairing/RecoveryScreen.kt new file mode 100644 index 00000000..f689522e --- /dev/null +++ b/app/src/main/java/app/closer/ui/pairing/RecoveryScreen.kt @@ -0,0 +1,164 @@ +package app.closer.ui.pairing + +import androidx.compose.foundation.background +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.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import app.closer.ui.components.StatusGlyph +import app.closer.ui.settings.SettingsBackgroundBrush +import app.closer.ui.settings.SettingsInk +import app.closer.ui.settings.SettingsMuted +import app.closer.ui.settings.SettingsOnPrimary +import app.closer.ui.settings.SettingsPrimary +import app.closer.ui.settings.SettingsPrimaryDeep + +@Composable +fun RecoveryScreen( + onRecovered: () -> Unit, + viewModel: RecoveryViewModel = hiltViewModel() +) { + val state by viewModel.uiState.collectAsState() + val snackbar = remember { SnackbarHostState() } + + LaunchedEffect(state.success) { + if (state.success) onRecovered() + } + LaunchedEffect(state.error) { + state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() } + } + + Scaffold( + snackbarHost = { SnackbarHost(snackbar) }, + containerColor = Color.Transparent, + modifier = Modifier.background(SettingsBackgroundBrush) + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .safeDrawingPadding() + .navigationBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(padding) + .padding(horizontal = 28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top + ) { + Spacer(Modifier.height(48.dp)) + + StatusGlyph( + icon = Icons.Filled.Lock, + tint = SettingsPrimaryDeep, + container = SettingsPrimary.copy(alpha = 0.12f) + ) + + Spacer(Modifier.height(20.dp)) + + Text( + "Unlock your history", + style = MaterialTheme.typography.headlineSmall, + color = SettingsInk, + fontWeight = FontWeight.SemiBold, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Enter the recovery phrase you and your partner were shown when pairing. Either partner's phrase works.", + style = MaterialTheme.typography.bodyMedium, + color = SettingsMuted, + textAlign = TextAlign.Center + ) + + Spacer(Modifier.height(28.dp)) + + OutlinedTextField( + value = state.phrase, + onValueChange = viewModel::onPhraseChanged, + modifier = Modifier.fillMaxWidth(), + label = { Text("Recovery phrase") }, + placeholder = { Text("word word word word word word") }, + singleLine = false, + minLines = 2, + shape = RoundedCornerShape(12.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = SettingsPrimaryDeep, + unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f), + focusedLabelColor = SettingsPrimaryDeep, + unfocusedLabelColor = SettingsMuted, + cursorColor = SettingsPrimaryDeep + ), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) + ) + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = viewModel::recover, + enabled = !state.isLoading, + modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = SettingsPrimary, + contentColor = SettingsOnPrimary + ) + ) { + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = SettingsOnPrimary, + strokeWidth = 2.dp + ) + } else { + Text("Unlock answers", style = MaterialTheme.typography.labelLarge) + } + } + + Spacer(Modifier.height(20.dp)) + + Text( + "If you've lost your phrase and don't have another device, your encrypted history cannot be recovered — this is by design.", + style = MaterialTheme.typography.bodySmall, + color = SettingsMuted, + textAlign = TextAlign.Center + ) + } + } +} diff --git a/app/src/main/java/app/closer/ui/pairing/RecoveryViewModel.kt b/app/src/main/java/app/closer/ui/pairing/RecoveryViewModel.kt new file mode 100644 index 00000000..d52a4eff --- /dev/null +++ b/app/src/main/java/app/closer/ui/pairing/RecoveryViewModel.kt @@ -0,0 +1,75 @@ +package app.closer.ui.pairing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.RecoveryKeyManager +import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +data class RecoveryUiState( + val phrase: String = "", + val isLoading: Boolean = false, + val error: String? = null, + val success: Boolean = false +) + +@HiltViewModel +class RecoveryViewModel @Inject constructor( + private val authRepository: AuthRepository, + private val coupleRepository: CoupleRepository, + private val encryptionManager: CoupleEncryptionManager +) : ViewModel() { + + private val _uiState = MutableStateFlow(RecoveryUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(phrase = phrase, error = null) } + + fun recover() { + val phrase = _uiState.value.phrase.trim() + if (phrase.isBlank()) { + _uiState.update { it.copy(error = "Enter your recovery phrase.") } + return + } + _uiState.update { it.copy(isLoading = true, error = null) } + viewModelScope.launch { + val userId = authRepository.currentUserId ?: run { + _uiState.update { it.copy(isLoading = false, error = "Not signed in.") } + return@launch + } + val couple = coupleRepository.getCoupleForUser(userId) + if (couple == null || couple.wrappedCoupleKey == null) { + _uiState.update { it.copy(isLoading = false, error = "Couldn't load couple data. Try again.") } + return@launch + } + val wrapped = RecoveryKeyManager.WrappedKey( + cipherB64 = couple.wrappedCoupleKey, + saltB64 = couple.kdfSalt ?: "", + params = couple.kdfParams ?: "" + ) + encryptionManager.unwrapAndStore(couple.id, wrapped, phrase) + .onSuccess { + _uiState.update { it.copy(isLoading = false, success = true) } + } + .onFailure { e -> + val msg = when { + e.message?.contains("AEADBadTag", ignoreCase = true) == true || + e.message?.contains("decryption", ignoreCase = true) == true -> + "That phrase doesn't match. Check with your partner and try again." + else -> "Recovery failed. Please check your phrase and try again." + } + _uiState.update { it.copy(isLoading = false, error = msg) } + } + } + } + + fun dismissError() = _uiState.update { it.copy(error = null) } +} diff --git a/firestore.rules b/firestore.rules index 89616c19..a55cce9c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -80,6 +80,11 @@ service cloud.firestore { match /notification_queue/{notificationId} { allow read, write: if false; } + + // FCM registration tokens: owner can read/write their own tokens. + match /fcmTokens/{tokenId} { + allow read, write: if isOwner(uid); + } } // ── Date ideas (read-only catalog) ───────────────────────────────────────── @@ -123,7 +128,8 @@ service cloud.firestore { && request.resource.data.expiresAt is timestamp && request.time < request.resource.data.expiresAt && request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt']) - && request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt']); + && request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt', + 'wrappedCoupleKey', 'kdfSalt', 'kdfParams']); // Update (accept): proper validation for changing status to accepted. // If coupleId is supplied, it must reference an existing couple where @@ -163,17 +169,26 @@ service cloud.firestore { // Read: both members can read allow read: if isCouplesMember(coupleId); - // Create: only via invite flow (server-side or admin SDK). - // Admin SDK bypasses rules; direct client writes are denied. - allow create: if false; + // Create: acceptor creates the couple doc during pairing (client-side). + // Must be a member of the couple and include required fields. + allow create: if isSignedIn() + && request.auth.uid in request.resource.data.userIds + && request.resource.data.keys().hasAll(['id', 'userIds', 'inviteCode', 'createdAt', 'streakCount']) + && request.resource.data.keys().hasOnly([ + 'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount', + 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']); // Update: field-level restrictions // - user IDs are immutable (cannot change who is in the couple) // - invite code is immutable (cannot change the code) // - createdAt is immutable (cannot change when the couple was formed) + // - encryptionVersion is monotonically non-decreasing (cannot downgrade) + // - wrappedCoupleKey/kdfSalt/kdfParams: mutable by members (passphrase change) // - All other fields (including streakCount and lastAnsweredAt): both members can update allow update: if isCouplesMember(coupleId) - && isImmutable(['userIds', 'inviteCode', 'createdAt']); + && isImmutable(['userIds', 'inviteCode', 'createdAt']) + && (resource.data.encryptionVersion == null + || request.resource.data.encryptionVersion >= resource.data.encryptionVersion); // Delete: server-only (admin SDK only). Admin SDK bypasses rules. allow delete: if false;