feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)

- 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
This commit is contained in:
null 2026-06-19 19:52:35 -05:00
parent 5caae523e7
commit 30fddcc2df
30 changed files with 1138 additions and 95 deletions

View File

@ -130,6 +130,10 @@ dependencies {
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") 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 // Debug
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")

View File

@ -4,6 +4,7 @@ import android.app.Application
import app.closer.core.firebase.FirebaseInitializer import app.closer.core.firebase.FirebaseInitializer
import app.closer.data.repository.ActivityProvider import app.closer.data.repository.ActivityProvider
import app.closer.domain.security.DeviceIntegrityChecker import app.closer.domain.security.DeviceIntegrityChecker
import com.google.crypto.tink.aead.AeadConfig
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -21,6 +22,7 @@ class CloserApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AeadConfig.register()
ActivityProvider.register(this) ActivityProvider.register(this)
firebaseInitializer.initialize() firebaseInitializer.initialize()
appScope.launch { deviceIntegrityChecker.runCheck() } appScope.launch { deviceIntegrityChecker.runCheck() }

View File

@ -43,6 +43,7 @@ import app.closer.ui.pairing.CreateInviteScreen
import app.closer.ui.pairing.EmailInviteScreen import app.closer.ui.pairing.EmailInviteScreen
import app.closer.ui.pairing.InviteConfirmScreen import app.closer.ui.pairing.InviteConfirmScreen
import app.closer.ui.pairing.PairPromptScreen import app.closer.ui.pairing.PairPromptScreen
import app.closer.ui.pairing.RecoveryScreen
import app.closer.ui.dates.DateMatchScreen import app.closer.ui.dates.DateMatchScreen
import app.closer.ui.dates.DateMatchesScreen import app.closer.ui.dates.DateMatchesScreen
import app.closer.ui.dates.DateBuilderScreen import app.closer.ui.dates.DateBuilderScreen
@ -276,6 +277,15 @@ fun AppNavigation(
onBack = navigateBackOrHome onBack = navigateBackOrHome
) )
} }
composable(route = AppRoute.RECOVERY) {
RecoveryScreen(
onRecovered = {
navController.navigate(AppRoute.HOME) {
popUpTo(AppRoute.RECOVERY) { inclusive = true }
}
}
)
}
// Wheel / Category Selection // Wheel / Category Selection
composable(route = AppRoute.CATEGORY_PICKER) { composable(route = AppRoute.CATEGORY_PICKER) {

View File

@ -49,6 +49,7 @@ object AppRoute {
const val CONNECTION_CHALLENGES = "connection_challenges" const val CONNECTION_CHALLENGES = "connection_challenges"
const val MEMORY_LANE = "memory_lane" const val MEMORY_LANE = "memory_lane"
const val WAITING_FOR_PARTNER = "waiting_for_partner" const val WAITING_FOR_PARTNER = "waiting_for_partner"
const val RECOVERY = "recovery"
// Question thread: coupleId and questionId are required; prevId and nextId are optional. // Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD = const val QUESTION_THREAD =

View File

@ -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<Unit> = 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<RecoveryKeyManager.WrappedKey> = 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)
}

View File

@ -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<String, Aead>()
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()
}

View File

@ -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:"
}
}

View File

@ -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"
)
}
}

View File

@ -1,8 +1,11 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.LocalAnswer import app.closer.domain.model.LocalAnswer
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONArray
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
@ -21,7 +24,11 @@ import kotlin.coroutines.resumeWithException
* both partners may read either answer. Firestore rules enforce this. * both partners may read either answer. Firestore rules enforce this.
*/ */
@Singleton @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) = private fun dailyQuestionRef(coupleId: String, date: String) =
db.collection(FirestoreCollections.COUPLES) db.collection(FirestoreCollections.COUPLES)
@ -43,13 +50,18 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
answer: LocalAnswer answer: LocalAnswer
): Unit = suspendCancellableCoroutine { cont -> ): Unit = suspendCancellableCoroutine { cont ->
val date = todayUtcString() val date = todayUtcString()
val aead = encryptionManager.aeadFor(coupleId)
val data = mapOf( val data = mapOf(
"userId" to userId, "userId" to userId,
"questionId" to questionId, "questionId" to questionId,
"answerType" to answer.answerType, "answerType" to answer.answerType,
"writtenText" to answer.writtenText, "writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
"selectedOptionIds" to answer.selectedOptionIds, "selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
"scaleValue" to answer.scaleValue, 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, "createdAt" to answer.createdAt,
"updatedAt" to answer.updatedAt, "updatedAt" to answer.updatedAt,
"isRevealed" to answer.isRevealed "isRevealed" to answer.isRevealed
@ -76,7 +88,8 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
cont.resume(null) cont.resume(null)
return@addOnSuccessListener return@addOnSuccessListener
} }
cont.resume(snap.toLocalAnswer()) val aead = encryptionManager.aeadFor(coupleId)
cont.resume(snap.toLocalAnswer(aead, coupleId))
} }
.addOnFailureListener { cont.resumeWithException(it) } .addOnFailureListener { cont.resumeWithException(it) }
} }
@ -120,17 +133,39 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer { private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(
val ids = get("selectedOptionIds") as? List<String> ?: emptyList() aead: com.google.crypto.tink.Aead?,
coupleId: String
): LocalAnswer {
val rawIds = get("selectedOptionIds") as? List<String> ?: 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( return LocalAnswer(
questionId = getString("questionId") ?: "", questionId = getString("questionId") ?: "",
questionText = "", questionText = "",
category = "", category = "",
answerType = getString("answerType") ?: "written", answerType = getString("answerType") ?: "written",
writtenText = getString("writtenText"), writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
selectedOptionIds = ids, selectedOptionIds = selectedOptionIds,
selectedOptionTexts = emptyList(), 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(), createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(), updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
isRevealed = getBoolean("isRevealed") ?: false isRevealed = getBoolean("isRevealed") ?: false

View File

@ -1,11 +1,11 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Couple import app.closer.domain.model.Couple
import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume 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 coupleRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId)
private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid) private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid)
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): String { /** [coupleId] is pre-generated by the repository so the keyset can be stored locally first. */
val coupleId = UUID.randomUUID().toString() suspend fun createCouple(
coupleId: String,
inviterUserId: String,
acceptorUserId: String,
inviteCode: String,
wrappedKey: RecoveryKeyManager.WrappedKey?
): String {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now) createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now, wrappedKey)
updateUserCoupleId(inviterUserId, coupleId) updateUserCoupleId(inviterUserId, coupleId)
updateUserCoupleId(acceptorUserId, coupleId) updateUserCoupleId(acceptorUserId, coupleId)
return coupleId return coupleId
@ -30,21 +36,41 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
inviterUserId: String, inviterUserId: String,
acceptorUserId: String, acceptorUserId: String,
inviteCode: String, inviteCode: String,
now: Long now: Long,
wrappedKey: RecoveryKeyManager.WrappedKey?
): Unit = suspendCancellableCoroutine { cont -> ): Unit = suspendCancellableCoroutine { cont ->
coupleRef(coupleId).set( val data = mutableMapOf<String, Any>(
mapOf( "id" to coupleId,
"id" to coupleId, "userIds" to listOf(inviterUserId, acceptorUserId),
"userIds" to listOf(inviterUserId, acceptorUserId), "inviteCode" to inviteCode,
"inviteCode" to inviteCode, "createdAt" to now,
"createdAt" to now, "streakCount" to 0
"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) } .addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) } .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 = private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
userRef(uid).set( userRef(uid).set(
@ -122,7 +148,11 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
currentQuestionId = getString("currentQuestionId"), currentQuestionId = getString("currentQuestionId"),
streakCount = (getLong("streakCount") ?: 0L).toInt(), streakCount = (getLong("streakCount") ?: 0L).toInt(),
lastAnsweredAt = getLong("lastAnsweredAt"), 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 { companion object {

View File

@ -1,11 +1,14 @@
package app.closer.data.remote 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.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -23,7 +26,9 @@ data class DesireSyncAnswers(
*/ */
@Singleton @Singleton
class FirestoreDesireSyncDataSource @Inject constructor( 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) = private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES) db.collection(FirestoreCollections.COUPLES)
@ -38,8 +43,12 @@ class FirestoreDesireSyncDataSource @Inject constructor(
userId: String, userId: String,
optionIds: List<String> optionIds: List<String>
) { ) {
val aead = encryptionManager.aeadFor(coupleId)
val value = if (aead != null)
listOf(fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId))
else optionIds
doc(coupleId, sessionId) doc(coupleId, sessionId)
.set(mapOf("answers" to mapOf(userId to optionIds)), SetOptions.merge()) .set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
.await() .await()
} }
@ -47,7 +56,8 @@ class FirestoreDesireSyncDataSource @Inject constructor(
suspend fun getAnswers(coupleId: String, sessionId: String): DesireSyncAnswers? = suspend fun getAnswers(coupleId: String, sessionId: String): DesireSyncAnswers? =
runCatching { runCatching {
val snap = doc(coupleId, sessionId).get().await() 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() }.getOrNull()
/** Live view of both partners' picks; emits whenever either side submits. */ /** Live view of both partners' picks; emits whenever either side submits. */
@ -55,16 +65,30 @@ class FirestoreDesireSyncDataSource @Inject constructor(
callbackFlow { callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener 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() } awaitClose { reg.remove() }
} }
private fun parseAnswers(raw: Any?): Map<String, List<String>> { private fun parseAnswers(
raw: Any?,
aead: com.google.crypto.tink.Aead?,
coupleId: String
): Map<String, List<String>> {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val map = raw as? Map<String, *> ?: return emptyMap() val map = raw as? Map<String, *> ?: return emptyMap()
return map.mapNotNull { (uid, value) -> return map.mapNotNull { (uid, value) ->
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it } val list = (value as? List<*>)?.filterIsInstance<String>() ?: 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() }.toMap()
} }
} }

View File

@ -1,11 +1,15 @@
package app.closer.data.remote 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.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@ -27,7 +31,9 @@ data class HowWellAnswers(
*/ */
@Singleton @Singleton
class FirestoreHowWellDataSource @Inject constructor( 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) = private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES) db.collection(FirestoreCollections.COUPLES)
@ -42,16 +48,28 @@ class FirestoreHowWellDataSource @Inject constructor(
userId: String, userId: String,
answers: List<HowWellRawAnswer> answers: List<HowWellRawAnswer>
) { ) {
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) doc(coupleId, sessionId)
.set(mapOf("answers" to mapOf(userId to payload)), SetOptions.merge()) .set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
.await() .await()
} }
/** One-shot read — used to detect whether this user has already answered. */ /** One-shot read — used to detect whether this user has already answered. */
suspend fun getAnswers(coupleId: String, sessionId: String): HowWellAnswers? = suspend fun getAnswers(coupleId: String, sessionId: String): HowWellAnswers? =
runCatching { 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() }.getOrNull()
/** Live view of both partners' answers; emits whenever either side submits. */ /** Live view of both partners' answers; emits whenever either side submits. */
@ -59,16 +77,36 @@ class FirestoreHowWellDataSource @Inject constructor(
callbackFlow { callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener 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() } awaitClose { reg.remove() }
} }
private fun parseAnswers(raw: Any?): Map<String, List<HowWellRawAnswer>> { private fun parseAnswers(
raw: Any?,
aead: com.google.crypto.tink.Aead?,
coupleId: String
): Map<String, List<HowWellRawAnswer>> {
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val map = raw as? Map<String, *> ?: return emptyMap() val map = raw as? Map<String, *> ?: return emptyMap()
return map.mapNotNull { (uid, value) -> 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 -> uid to list.mapNotNull { item ->
(item as? Map<*, *>)?.let { (item as? Map<*, *>)?.let {
HowWellRawAnswer( HowWellRawAnswer(

View File

@ -1,5 +1,6 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Invite import app.closer.domain.model.Invite
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions 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)] } .map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
.joinToString("") .joinToString("")
suspend fun createInvite(code: String, inviterUserId: String): Unit = suspend fun createInvite(
suspendCancellableCoroutine { cont -> code: String,
val now = System.currentTimeMillis() inviterUserId: String,
inviteRef(code).set( wrappedKey: RecoveryKeyManager.WrappedKey
mapOf( ): Unit = suspendCancellableCoroutine { cont ->
"code" to code, val now = System.currentTimeMillis()
"inviterUserId" to inviterUserId, inviteRef(code).set(
"status" to "pending", mapOf(
"createdAt" to now, "code" to code,
"expiresAt" to now + 24 * 60 * 60 * 1000L "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? = suspend fun getInviteByCode(code: String): Invite? =
suspendCancellableCoroutine { cont -> suspendCancellableCoroutine { cont ->
@ -50,7 +57,10 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
createdAt = snap.getLong("createdAt") ?: 0L, createdAt = snap.getLong("createdAt") ?: 0L,
expiresAt = snap.getLong("expiresAt") ?: 0L, expiresAt = snap.getLong("expiresAt") ?: 0L,
acceptedAt = snap.getLong("acceptedAt"), acceptedAt = snap.getLong("acceptedAt"),
acceptedByUserId = snap.getString("acceptedByUserId") acceptedByUserId = snap.getString("acceptedByUserId"),
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
kdfSalt = snap.getString("kdfSalt"),
kdfParams = snap.getString("kdfParams")
) )
) )
} }

View File

@ -1,10 +1,13 @@
package app.closer.data.remote 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.QuestionAnswer
import app.closer.domain.model.QuestionMessage import app.closer.domain.model.QuestionMessage
import app.closer.domain.model.QuestionReaction import app.closer.domain.model.QuestionReaction
import app.closer.domain.model.QuestionThread import app.closer.domain.model.QuestionThread
import app.closer.domain.model.QuestionThreadStatus import app.closer.domain.model.QuestionThreadStatus
import org.json.JSONArray
import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldValue import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
@ -19,7 +22,11 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException import kotlin.coroutines.resumeWithException
@Singleton @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) = private fun threadsRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId) 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) { suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
val now = FieldValue.serverTimestamp() val now = FieldValue.serverTimestamp()
val aead = encryptionManager.aeadFor(coupleId)
threadsRef(coupleId) threadsRef(coupleId)
.document(threadId) .document(threadId)
.collection(FirestoreCollections.QuestionThreads.ANSWERS) .collection(FirestoreCollections.QuestionThreads.ANSWERS)
@ -80,9 +88,13 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
"userId" to answer.userId, "userId" to answer.userId,
"questionId" to answer.questionId, "questionId" to answer.questionId,
"answerType" to answer.answerType, "answerType" to answer.answerType,
"writtenText" to answer.writtenText, "writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
"selectedOptionIds" to answer.selectedOptionIds, "selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
"scaleValue" to answer.scaleValue, 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, "createdAt" to now,
"updatedAt" to now "updatedAt" to now
) )
@ -103,7 +115,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
.collection(FirestoreCollections.QuestionThreads.ANSWERS) .collection(FirestoreCollections.QuestionThreads.ANSWERS)
.addSnapshotListener { snap, err -> .addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener 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() } awaitClose { listener.remove() }
} }
@ -111,13 +124,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
// ─── Messages ──────────────────────────────────────────────────────────────── // ─── Messages ────────────────────────────────────────────────────────────────
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) { suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) {
val aead = encryptionManager.aeadFor(coupleId)
threadsRef(coupleId) threadsRef(coupleId)
.document(threadId) .document(threadId)
.collection(FirestoreCollections.QuestionThreads.MESSAGES) .collection(FirestoreCollections.QuestionThreads.MESSAGES)
.add( .add(
mapOf( mapOf(
"authorUserId" to message.userId, "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() "createdAt" to FieldValue.serverTimestamp()
) )
).refAwait() ).refAwait()
@ -130,7 +144,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
.orderBy("createdAt", Query.Direction.ASCENDING) .orderBy("createdAt", Query.Direction.ASCENDING)
.addSnapshotListener { snap, err -> .addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener 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() } awaitClose { listener.remove() }
} }
@ -204,26 +219,51 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
) )
@Suppress("UNCHECKED_CAST") @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 userId = getString("userId") ?: return null
val rawIds = (get("selectedOptionIds") as? List<String>) ?: 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( return QuestionAnswer(
userId = userId, userId = userId,
questionId = getString("questionId") ?: "", questionId = getString("questionId") ?: "",
answerType = getString("answerType") ?: "written", answerType = getString("answerType") ?: "written",
writtenText = getString("writtenText"), writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
selectedOptionIds = (get("selectedOptionIds") as? List<String>) ?: emptyList(), selectedOptionIds = selectedOptionIds,
scaleValue = getLong("scaleValue")?.toInt(), scaleValue = scaleValue,
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L, createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
updatedAt = getTimestamp("updatedAt")?.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 val userId = getString("authorUserId") ?: return null
return QuestionMessage( return QuestionMessage(
id = id, id = id,
userId = userId, userId = userId,
text = getString("text") ?: "", text = fieldEncryptor.decrypt(getString("text"), aead, coupleId) ?: "",
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
) )
} }

View File

@ -1,5 +1,7 @@
package app.closer.data.remote 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.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions import com.google.firebase.firestore.SetOptions
@ -32,7 +34,9 @@ data class WheelRevealDoc(
*/ */
@Singleton @Singleton
class FirestoreWheelAnswerDataSource @Inject constructor( 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) = private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES) db.collection(FirestoreCollections.COUPLES)
@ -49,11 +53,17 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
questions: List<WheelQuestionRef>, questions: List<WheelQuestionRef>,
answers: List<WheelAnswerEntry> answers: List<WheelAnswerEntry>
) { ) {
val aead = encryptionManager.aeadFor(coupleId)
val data = mapOf( val data = mapOf(
"categoryName" to categoryName, "categoryName" to categoryName,
"questions" to questions.map { mapOf("id" to it.id, "text" to it.text) }, "questions" to questions.map { mapOf("id" to it.id, "text" to it.text) },
"answers" to mapOf( "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() 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. */ /** One-shot read — used to detect whether this user has already answered. */
suspend fun getDoc(coupleId: String, sessionId: String): WheelRevealDoc? = 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. */ /** Live view of both partners' answers; emits whenever either side submits. */
fun observe(coupleId: String, sessionId: String): Flow<WheelRevealDoc> = callbackFlow { fun observe(coupleId: String, sessionId: String): Flow<WheelRevealDoc> = callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err -> val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener if (err != null || snap == null) return@addSnapshotListener
trySend(parse(snap)) trySend(parse(snap, coupleId))
} }
awaitClose { reg.remove() } 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 -> val questions = (snap.get("questions") as? List<*>).orEmpty().mapNotNull { item ->
(item as? Map<*, *>)?.let { (item as? Map<*, *>)?.let {
WheelQuestionRef( WheelQuestionRef(
@ -86,9 +97,10 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) -> val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
(value as? List<*>).orEmpty().mapNotNull { item -> (value as? List<*>).orEmpty().mapNotNull { item ->
(item as? Map<*, *>)?.let { (item as? Map<*, *>)?.let {
val rawDisplay = it["display"] as? String ?: ""
WheelAnswerEntry( WheelAnswerEntry(
questionId = it["questionId"] as? String ?: "", questionId = it["questionId"] as? String ?: "",
display = it["display"] as? String ?: "" display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
) )
} }
} }

View File

@ -1,17 +1,23 @@
package app.closer.data.repository package app.closer.data.repository
import app.closer.core.crash.CrashReporter 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.FirestoreCoupleDataSource
import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.data.remote.FirestoreUserDataSource import app.closer.data.remote.FirestoreUserDataSource
import app.closer.domain.model.Couple import app.closer.domain.model.Couple
import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.CoupleRepository
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class CoupleRepositoryImpl @Inject constructor( class CoupleRepositoryImpl @Inject constructor(
private val coupleDataSource: FirestoreCoupleDataSource, private val coupleDataSource: FirestoreCoupleDataSource,
private val inviteDataSource: FirestoreInviteDataSource,
private val userDataSource: FirestoreUserDataSource, private val userDataSource: FirestoreUserDataSource,
private val encryptionManager: CoupleEncryptionManager,
private val crashReporter: CrashReporter private val crashReporter: CrashReporter
) : CoupleRepository { ) : CoupleRepository {
@ -24,8 +30,30 @@ class CoupleRepositoryImpl @Inject constructor(
.getOrNull() .getOrNull()
} }
override suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String> = runCatching { override suspend fun createCouple(
coupleDataSource.createCouple(inviterUserId, acceptorUserId, inviteCode) inviterUserId: String,
acceptorUserId: String,
inviteCode: String,
recoveryPhrase: String
): Result<String> = 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<Unit> = runCatching { override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching {
@ -33,6 +61,13 @@ class CoupleRepositoryImpl @Inject constructor(
} }
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching { override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
val coupleId = userDataSource.getUser(userId)?.coupleId
coupleDataSource.leaveCouple(userId) coupleDataSource.leaveCouple(userId)
if (coupleId != null) encryptionManager.deleteKeyset(coupleId)
}
override suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result<Unit> = runCatching {
val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it }
coupleDataSource.updateWrappedKey(coupleId, newWrapped)
} }
} }

View File

@ -1,20 +1,24 @@
package app.closer.data.repository package app.closer.data.repository
import app.closer.crypto.CoupleEncryptionManager
import app.closer.data.remote.FirestoreInviteDataSource import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.domain.model.Invite import app.closer.domain.model.Invite
import app.closer.domain.repository.CreateInviteResult
import app.closer.domain.repository.InviteRepository import app.closer.domain.repository.InviteRepository
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class InviteRepositoryImpl @Inject constructor( class InviteRepositoryImpl @Inject constructor(
private val dataSource: FirestoreInviteDataSource private val dataSource: FirestoreInviteDataSource,
private val encryptionManager: CoupleEncryptionManager
) : InviteRepository { ) : InviteRepository {
override suspend fun createInvite(inviterUserId: String): Result<String> = runCatching { override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
val code = dataSource.generateCode() val code = dataSource.generateCode()
dataSource.createInvite(code, inviterUserId) val setup = encryptionManager.setupForNewCouple(code)
code dataSource.createInvite(code, inviterUserId, setup.wrapped)
CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase)
} }
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching { override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching {

View File

@ -8,5 +8,10 @@ data class Couple(
val currentQuestionId: String? = null, val currentQuestionId: String? = null,
val streakCount: Int = 0, val streakCount: Int = 0,
val lastAnsweredAt: Long? = null, 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
) )

View File

@ -10,5 +10,9 @@ data class Invite(
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val expiresAt: Long = System.currentTimeMillis() + 24 * 60 * 60 * 1000L, val expiresAt: Long = System.currentTimeMillis() + 24 * 60 * 60 * 1000L,
val acceptedAt: Long? = null, 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
) )

View File

@ -4,7 +4,8 @@ import app.closer.domain.model.Couple
interface CoupleRepository { interface CoupleRepository {
suspend fun getCoupleForUser(userId: String): Couple? suspend fun getCoupleForUser(userId: String): Couple?
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String> suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String, recoveryPhrase: String): Result<String>
suspend fun updateStreak(coupleId: String): Result<Unit> suspend fun updateStreak(coupleId: String): Result<Unit>
suspend fun leaveCouple(userId: String): Result<Unit> suspend fun leaveCouple(userId: String): Result<Unit>
suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result<Unit>
} }

View File

@ -2,8 +2,10 @@ package app.closer.domain.repository
import app.closer.domain.model.Invite import app.closer.domain.model.Invite
data class CreateInviteResult(val code: String, val recoveryPhrase: String)
interface InviteRepository { interface InviteRepository {
suspend fun createInvite(inviterUserId: String): Result<String> suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
suspend fun getInviteByCode(code: String): Result<Invite?> suspend fun getInviteByCode(code: String): Result<Invite?>
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit> suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit>
} }

View File

@ -77,6 +77,12 @@ fun HomeScreen(
} }
} }
LaunchedEffect(state.needsRecovery) {
if (state.needsRecovery) {
onNavigate(app.closer.core.navigation.AppRoute.RECOVERY)
}
}
HomeContent( HomeContent(
state = state, state = state,
snackbarHostState = snackbarHostState, snackbarHostState = snackbarHostState,

View File

@ -3,6 +3,8 @@ package app.closer.ui.home
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.LocalAnswer
import app.closer.domain.model.Question import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory import app.closer.domain.model.QuestionCategory
@ -73,7 +75,8 @@ data class HomeUiState(
val isPaired: Boolean = false, val isPaired: Boolean = false,
val primaryAction: HomeAction? = null, val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList(), val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -83,6 +86,7 @@ class HomeViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val encryptionManager: CoupleEncryptionManager,
private val db: FirebaseFirestore private val db: FirebaseFirestore
) : ViewModel() { ) : ViewModel() {
@ -123,6 +127,8 @@ class HomeViewModel @Inject constructor(
.onFailure { Log.w(TAG, "Could not load partner display name", it) } .onFailure { Log.w(TAG, "Could not load partner display name", it) }
.getOrNull() .getOrNull()
} }
val needsRecovery = couple != null &&
encryptionManager.checkStatus(couple) == EncryptionStatus.NEEDS_RECOVERY
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
@ -131,7 +137,8 @@ class HomeViewModel @Inject constructor(
partnerName = partnerName, partnerName = partnerName,
streakCount = couple?.streakCount ?: 0, streakCount = couple?.streakCount ?: 0,
isPaired = couple != null, isPaired = couple != null,
partnerLeftEvent = false partnerLeftEvent = false,
needsRecovery = needsRecovery
).withHomeActions() ).withHomeActions()
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -182,6 +189,12 @@ class HomeViewModel @Inject constructor(
_uiState.update { it.copy(partnerLeftEvent = false) } _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() { private fun observeAnswers() {
viewModelScope.launch { viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers -> localAnswerRepository.observeAnswers().collect { answers ->

View File

@ -23,6 +23,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Share 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.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
@ -233,6 +235,69 @@ fun CreateInviteScreen(
textAlign = TextAlign.Center 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)) Spacer(Modifier.height(28.dp))
TextButton( TextButton(

View File

@ -17,6 +17,7 @@ import javax.inject.Inject
data class CreateInviteUiState( data class CreateInviteUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val inviteCode: String? = null, val inviteCode: String? = null,
val recoveryPhrase: String? = null,
val error: String? = null, val error: String? = null,
val navigateTo: String? = null val navigateTo: String? = null
) )
@ -43,8 +44,8 @@ class CreateInviteViewModel @Inject constructor(
return@launch return@launch
} }
inviteRepository.createInvite(userId) inviteRepository.createInvite(userId)
.onSuccess { code -> .onSuccess { result ->
_uiState.update { it.copy(isLoading = false, inviteCode = code) } _uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
} }
.onFailure { e -> .onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't create invite. Please try again.") } _uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't create invite. Please try again.") }

View File

@ -25,6 +25,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@ -41,6 +43,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -142,7 +146,39 @@ fun InviteConfirmScreen(
color = SettingsMuted 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( Button(
onClick = viewModel::confirmPairing, onClick = viewModel::confirmPairing,

View File

@ -21,9 +21,11 @@ import javax.inject.Inject
data class InviteConfirmUiState( data class InviteConfirmUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val inviterName: String? = null, val inviterName: String? = null,
val recoveryPhrase: String = "",
val error: String? = null, val error: String? = null,
val navigateTo: String? = null, val navigateTo: String? = null,
val isConfirming: Boolean = false val isConfirming: Boolean = false,
val isEncryptedInvite: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -51,7 +53,13 @@ class InviteConfirmViewModel @Inject constructor(
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) } .onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
.getOrNull() .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 { .onFailure {
_uiState.update { it.copy(isLoading = false, error = "Couldn't load invite details. Please go back and try again.") } _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() { fun confirmPairing() {
val acceptorId = authRepository.currentUserId ?: run { val acceptorId = authRepository.currentUserId ?: run {
_uiState.update { it.copy(error = "Not signed in.") } _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.") } _uiState.update { it.copy(error = "Invite not loaded yet.") }
return 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) } _uiState.update { it.copy(isConfirming = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode) coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode, phrase)
.onSuccess { coupleId -> .onSuccess { coupleId ->
inviteRepository.markAccepted(inviteCode, acceptorId, coupleId) inviteRepository.markAccepted(inviteCode, acceptorId, coupleId)
_uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) } _uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) }
} }
.onFailure { e -> .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) }
} }
} }
} }

View File

@ -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
)
}
}
}

View File

@ -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<RecoveryUiState> = _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) }
}

View File

@ -80,6 +80,11 @@ service cloud.firestore {
match /notification_queue/{notificationId} { match /notification_queue/{notificationId} {
allow read, write: if false; 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) ───────────────────────────────────────── // ── Date ideas (read-only catalog) ─────────────────────────────────────────
@ -123,7 +128,8 @@ service cloud.firestore {
&& request.resource.data.expiresAt is timestamp && request.resource.data.expiresAt is timestamp
&& request.time < request.resource.data.expiresAt && request.time < request.resource.data.expiresAt
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', '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. // Update (accept): proper validation for changing status to accepted.
// If coupleId is supplied, it must reference an existing couple where // If coupleId is supplied, it must reference an existing couple where
@ -163,17 +169,26 @@ service cloud.firestore {
// Read: both members can read // Read: both members can read
allow read: if isCouplesMember(coupleId); allow read: if isCouplesMember(coupleId);
// Create: only via invite flow (server-side or admin SDK). // Create: acceptor creates the couple doc during pairing (client-side).
// Admin SDK bypasses rules; direct client writes are denied. // Must be a member of the couple and include required fields.
allow create: if false; 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 // Update: field-level restrictions
// - user IDs are immutable (cannot change who is in the couple) // - user IDs are immutable (cannot change who is in the couple)
// - invite code is immutable (cannot change the code) // - invite code is immutable (cannot change the code)
// - createdAt is immutable (cannot change when the couple was formed) // - 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 // - All other fields (including streakCount and lastAnsweredAt): both members can update
allow update: if isCouplesMember(coupleId) 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. // Delete: server-only (admin SDK only). Admin SDK bypasses rules.
allow delete: if false; allow delete: if false;