diff --git a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt index 9c70b624..e2e92bda 100644 --- a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt +++ b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt @@ -145,10 +145,29 @@ class SealedRevealManager @Inject constructor( encryptedAnswerKey = keybox ) - pendingAnswerKeyStore.remove(storeKey) + // Unlike daily answers (whose plaintext is mirrored to local storage), a thread answer's + // only plaintext source on this device is this one-time key. Keep it so the reveal screen + // can re-decrypt our own answer on cold restart. return true } + /** Decrypts the user's OWN sealed thread answer using their locally-held one-time key. */ + suspend fun decryptOwnThreadAnswer( + coupleId: String, + threadId: String, + userId: String, + encryptedPayload: String + ): SealedAnswerEncryptor.AnswerPayload? { + val oneTimeKey = pendingAnswerKeyStore.load("thread_$threadId") ?: return null + return sealedAnswerEncryptor.open( + encryptedPayload = encryptedPayload, + keyHandle = oneTimeKey, + coupleId = coupleId, + questionId = threadId, + userId = userId + ) + } + suspend fun decryptPartnerThreadAnswer( coupleId: String, threadId: String, 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 0cd3c14b..1ebf8afa 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt @@ -8,6 +8,9 @@ import app.closer.crypto.SealedAnswerEncryptor import app.closer.crypto.UserKeyManager import app.closer.domain.model.LocalAnswer import com.google.firebase.firestore.FirebaseFirestore +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.suspendCancellableCoroutine import org.json.JSONArray import java.time.Clock @@ -134,6 +137,24 @@ class FirestoreAnswerDataSource @Inject constructor( .addOnFailureListener { cont.resumeWithException(it) } } + /** + * Live view of a partner's answer doc — emits whenever they submit or release their + * reveal key. Lets the reveal screen complete the client-side decryption in real time + * instead of waiting for a manual refresh. Errors are swallowed (the screen keeps its + * current state) so the flow never crashes the collector. + */ + fun observeAnswerForUser( + coupleId: String, + userId: String, + date: String = todayLocalDateString() + ): Flow = callbackFlow { + val reg = answerRef(coupleId, date, userId).addSnapshotListener { snap, err -> + if (err != null) return@addSnapshotListener + trySend(snap?.takeIf { it.exists() }?.toLocalAnswer()) + } + awaitClose { reg.remove() } + } + /** * Reads the couple-scoped daily question assignment for today. */ diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt index 032ef615..4aec9ab2 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreDatePlanDataSource.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.DatePlan import app.closer.domain.model.DatePlanPreference import app.closer.domain.model.DatePlanStatus import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions +import org.json.JSONArray import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow @@ -25,7 +28,11 @@ import kotlin.coroutines.resumeWithException * Local-first: Preferences stored in Room. Plans synced to Firestore when explicitly shared/scheduled. */ @Singleton -class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFirestore) { +class FirestoreDatePlanDataSource @Inject constructor( + private val db: FirebaseFirestore, + private val encryptionManager: CoupleEncryptionManager, + private val fieldEncryptor: FieldEncryptor +) { private fun preferencesRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId) .collection(FirestoreCollections.Couples.DATE_PLAN_PREFERENCES) @@ -37,13 +44,16 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi // ─── Preference methods ────────────────────────────────────────────────── suspend fun recordPreference(coupleId: String, preference: DatePlanPreference) { + // Strict E2EE: user-entered details are encrypted. dateIdeaId stays plaintext + // (queried via whereEqualTo); dates/timestamps stay plaintext (structural). + val aead = encryptionManager.requireAead(coupleId) val path = preferencesRef(coupleId).document() val data = mapOf( "dateIdeaId" to preference.dateIdeaId, "preferredDate" to preference.preferredDate, - "preferredTime" to preference.preferredTime, - "budget" to preference.budget, - "duration" to preference.duration, + "preferredTime" to fieldEncryptor.encrypt(preference.preferredTime, aead, coupleId), + "budget" to fieldEncryptor.encrypt(preference.budget.toString(), aead, coupleId), + "duration" to fieldEncryptor.encrypt(preference.duration, aead, coupleId), "createdAt" to preference.createdAt, "updatedAt" to preference.updatedAt ) @@ -74,40 +84,36 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi suspend fun createPlan(coupleId: String, plan: DatePlan): String { val doc = plansRef(coupleId).document() - val data = mapOf( - "dateIdeaId" to plan.dateIdeaId, - "scheduledDate" to plan.scheduledDate, - "scheduledTime" to plan.scheduledTime, - "budget" to plan.budget, - "duration" to plan.duration, - "status" to plan.status.toFirestoreValue(), - "activity" to plan.activity, - "food" to plan.food, - "conversationPrompts" to plan.conversationPrompts, - "optionalChallenge" to plan.optionalChallenge, - "createdAt" to plan.createdAt, - "updatedAt" to plan.updatedAt - ) - doc.set(data, SetOptions.merge()).voidAwait() + doc.set(plan.toEncryptedData(coupleId, includeCreatedAt = true), SetOptions.merge()).voidAwait() return doc.id } suspend fun updatePlan(coupleId: String, plan: DatePlan) { val path = plansRef(coupleId).document(plan.id) - val data = mapOf( - "dateIdeaId" to plan.dateIdeaId, - "scheduledDate" to plan.scheduledDate, - "scheduledTime" to plan.scheduledTime, - "budget" to plan.budget, - "duration" to plan.duration, - "status" to plan.status.toFirestoreValue(), - "activity" to plan.activity, - "food" to plan.food, - "conversationPrompts" to plan.conversationPrompts, - "optionalChallenge" to plan.optionalChallenge, - "updatedAt" to plan.updatedAt + path.set(plan.toEncryptedData(coupleId, includeCreatedAt = false), SetOptions.merge()).voidAwait() + } + + /** + * Builds the Firestore payload with user content encrypted. dateIdeaId / scheduledDate / + * status / timestamps stay plaintext because Firestore queries and ordering depend on them. + */ + private fun DatePlan.toEncryptedData(coupleId: String, includeCreatedAt: Boolean): Map { + val aead = encryptionManager.requireAead(coupleId) + val data = mutableMapOf( + "dateIdeaId" to dateIdeaId, + "scheduledDate" to scheduledDate, + "scheduledTime" to fieldEncryptor.encrypt(scheduledTime, aead, coupleId), + "budget" to fieldEncryptor.encrypt(budget.toString(), aead, coupleId), + "duration" to fieldEncryptor.encrypt(duration, aead, coupleId), + "status" to status.toFirestoreValue(), + "activity" to fieldEncryptor.encrypt(activity, aead, coupleId), + "food" to fieldEncryptor.encrypt(food, aead, coupleId), + "conversationPrompts" to fieldEncryptor.encrypt(JSONArray(conversationPrompts).toString(), aead, coupleId), + "optionalChallenge" to fieldEncryptor.encryptNullable(optionalChallenge, aead, coupleId), + "updatedAt" to updatedAt ) - path.set(data, SetOptions.merge()).voidAwait() + if (includeCreatedAt) data["createdAt"] = createdAt + return data } suspend fun getPlan(coupleId: String, planId: String): DatePlan? { @@ -163,39 +169,46 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi // ─── Mappers ───────────────────────────────────────────────────────────── - @Suppress("UNCHECKED_CAST") private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? { val dateIdeaId = getString("dateIdeaId") ?: return null + val aead = encryptionManager.aeadFor(coupleId) return DatePlanPreference( id = id, coupleId = coupleId, dateIdeaId = dateIdeaId, preferredDate = (get("preferredDate") as? Number)?.toLong() ?: 0L, - preferredTime = getString("preferredTime") ?: "", - budget = (get("budget") as? Number)?.toInt() ?: 0, - duration = getString("duration") ?: "", + preferredTime = fieldEncryptor.decryptForDisplay(getString("preferredTime"), aead, coupleId) ?: "", + budget = fieldEncryptor.decrypt(getString("budget"), aead, coupleId)?.toIntOrNull() ?: 0, + duration = fieldEncryptor.decryptForDisplay(getString("duration"), aead, coupleId) ?: "", createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L, updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L ) } - @Suppress("UNCHECKED_CAST") private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlan(coupleId: String): DatePlan? { val dateIdeaId = getString("dateIdeaId") ?: return null val scheduledDate = (get("scheduledDate") as? Number)?.toLong() ?: 0L + val aead = encryptionManager.aeadFor(coupleId) + val prompts = runCatching { + val json = fieldEncryptor.decrypt(getString("conversationPrompts"), aead, coupleId) + if (json != null) { + val arr = JSONArray(json) + (0 until arr.length()).map { arr.getString(it) } + } else emptyList() + }.getOrDefault(emptyList()) return DatePlan( id = id, coupleId = coupleId, dateIdeaId = dateIdeaId, scheduledDate = scheduledDate, - scheduledTime = getString("scheduledTime") ?: "", - budget = (get("budget") as? Number)?.toInt() ?: 0, - duration = getString("duration") ?: "", + scheduledTime = fieldEncryptor.decryptForDisplay(getString("scheduledTime"), aead, coupleId) ?: "", + budget = fieldEncryptor.decrypt(getString("budget"), aead, coupleId)?.toIntOrNull() ?: 0, + duration = fieldEncryptor.decryptForDisplay(getString("duration"), aead, coupleId) ?: "", status = DatePlanStatus.fromFirestoreValue(getString("status") ?: "draft"), - activity = getString("activity") ?: "", - food = getString("food") ?: "", - conversationPrompts = (get("conversationPrompts") as? List) ?: emptyList(), - optionalChallenge = getString("optionalChallenge"), + activity = fieldEncryptor.decryptForDisplay(getString("activity"), aead, coupleId) ?: "", + food = fieldEncryptor.decryptForDisplay(getString("food"), aead, coupleId) ?: "", + conversationPrompts = prompts, + optionalChallenge = fieldEncryptor.decryptForDisplay(getString("optionalChallenge"), aead, coupleId), createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L, updatedAt = (get("updatedAt") as? Number)?.toLong() ?: 0L ) diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt index 45df19c4..56c1cdbb 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt @@ -18,6 +18,7 @@ import app.closer.domain.repository.LocalAnswerRepository import app.closer.domain.repository.QuestionRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -86,6 +87,8 @@ class AnswerRevealViewModel @Inject constructor( private val _uiState = MutableStateFlow(AnswerRevealUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var partnerObserveJob: Job? = null + init { load() observeAnswer() @@ -121,6 +124,12 @@ class AnswerRevealViewModel @Inject constructor( sealedRevealPhase = sealedPhase, followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category) ) + + // Live-watch the partner's answer doc so the reveal completes on this device + // the moment they release their key — no manual refresh needed. + if (coupleId != null && partnerId != null) { + observePartnerAnswerLive(coupleId, partnerId) + } } catch (e: Exception) { crashReporter.recordException(e) _uiState.value = AnswerRevealUiState( @@ -160,6 +169,36 @@ class AnswerRevealViewModel @Inject constructor( } } + /** + * Watches the partner's answer doc live. When they submit (BOTH_ANSWERED appears) or + * release their reveal key, the sealed phase is recomputed and — if our key is already + * out — the partner's answer is decrypted on-device immediately. Decryption never leaves + * the client; this only changes *when* it happens, not who can do it. + */ + private fun observePartnerAnswerLive(coupleId: String, partnerId: String) { + partnerObserveJob?.cancel() + partnerObserveJob = viewModelScope.launch { + val date = effectiveDate(_uiState.value.answer) + firestoreAnswerDataSource.observeAnswerForUser(coupleId, partnerId, date).collect { partnerAnswer -> + if (partnerAnswer == null) return@collect + val current = _uiState.value + // Once revealed, the partner answer in state is already decrypted — never + // overwrite it with the sealed doc. Also leave an in-flight key release alone. + if (current.sealedRevealPhase == SealedRevealPhase.REVEALED || + current.sealedRevealPhase == SealedRevealPhase.RELEASING_KEY + ) { + return@collect + } + val newPhase = computeSealedPhase(current.answer, partnerAnswer) + _uiState.update { it.copy(partnerAnswer = partnerAnswer, sealedRevealPhase = newPhase) } + // Our key is already released and the partner just released theirs → finish now. + if (newPhase == SealedRevealPhase.WAITING_FOR_PARTNER) { + tryDecryptPartnerAnswer(coupleId, partnerId, _uiState.value) + } + } + } + } + fun refreshPartnerAnswer() { val state = _uiState.value val coupleId = state.coupleId ?: return diff --git a/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt b/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt index d95780c9..00c17169 100644 --- a/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt +++ b/app/src/main/java/app/closer/ui/questions/QuestionThreadViewModel.kt @@ -3,6 +3,7 @@ package app.closer.ui.questions import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.crypto.SealedRevealManager import app.closer.data.local.QuestionDao import app.closer.data.local.mapper.toQuestion import app.closer.domain.model.ChoiceAnswerConfigImpl @@ -46,6 +47,7 @@ data class QuestionThreadUiState( class QuestionThreadViewModel @Inject constructor( private val repository: QuestionThreadRepository, private val questionDao: QuestionDao, + private val sealedRevealManager: SealedRevealManager, savedStateHandle: SavedStateHandle ) : ViewModel() { @@ -53,6 +55,9 @@ class QuestionThreadViewModel @Inject constructor( private val questionId: String = savedStateHandle["questionId"] ?: "" val currentUserId: String = FirebaseAuth.getInstance().currentUser?.uid ?: "" + // Released-once guard for our thread reveal key. + private var threadKeyReleased = false + private val _uiState = MutableStateFlow( QuestionThreadUiState( previousQuestionId = savedStateHandle["prevId"], @@ -81,20 +86,7 @@ class QuestionThreadViewModel @Inject constructor( launch { repository.observeAnswers(coupleId, threadId).collect { answers -> - val my = answers.find { it.userId == currentUserId } - val partner = answers.find { it.userId != currentUserId } - val phase = when { - my == null -> QuestionPhase.INPUT - partner == null -> QuestionPhase.WAITING - else -> QuestionPhase.REVEALED - } - _uiState.update { state -> - state.copy( - phase = phase, - myAnswer = my, - partnerAnswer = if (phase == QuestionPhase.REVEALED) partner else null - ) - } + handleAnswers(threadId, answers) } } launch { @@ -113,6 +105,71 @@ class QuestionThreadViewModel @Inject constructor( } } + /** + * Drives the thread reveal entirely on-device. Both answers are sealed (schemaVersion 3): + * once both partners have answered, we release our one-time key (so the partner can decrypt + * us) and decrypt their answer with our private key. Our own answer is decrypted with the + * one-time key held locally. The server never sees plaintext or any usable key. + */ + private suspend fun handleAnswers(threadId: String, answers: List) { + val mySealed = answers.find { it.userId == currentUserId } + val partnerSealed = answers.find { it.userId != currentUserId } + when { + mySealed == null -> + _uiState.update { it.copy(phase = QuestionPhase.INPUT, myAnswer = null, partnerAnswer = null) } + + partnerSealed == null -> + _uiState.update { + it.copy(phase = QuestionPhase.WAITING, myAnswer = decryptOwn(threadId, mySealed), partnerAnswer = null) + } + + else -> { + // Both answered — release our key so the partner can decrypt us, then decrypt theirs. + releaseThreadKeyOnce(threadId, partnerSealed.userId) + val mine = decryptOwn(threadId, mySealed) + val partner = decryptPartner(threadId, partnerSealed) + if (partner != null) { + _uiState.update { it.copy(phase = QuestionPhase.REVEALED, myAnswer = mine, partnerAnswer = partner) } + } else { + // Partner answered but hasn't released their key yet — keep waiting. + _uiState.update { it.copy(phase = QuestionPhase.WAITING, myAnswer = mine, partnerAnswer = null) } + } + } + } + } + + private suspend fun releaseThreadKeyOnce(threadId: String, partnerId: String) { + if (threadKeyReleased) return + val released = runCatching { + sealedRevealManager.releaseOwnKeyForThread(coupleId, threadId, currentUserId, partnerId) + }.getOrDefault(false) + if (released) threadKeyReleased = true + } + + private suspend fun decryptOwn(threadId: String, sealed: QuestionAnswer): QuestionAnswer { + val payload = sealed.encryptedPayload ?: return sealed + val decrypted = runCatching { + sealedRevealManager.decryptOwnThreadAnswer(coupleId, threadId, currentUserId, payload) + }.getOrNull() ?: return sealed + return sealed.copy( + writtenText = decrypted.writtenText, + selectedOptionIds = decrypted.selectedOptionIds, + scaleValue = decrypted.scaleValue + ) + } + + private suspend fun decryptPartner(threadId: String, sealed: QuestionAnswer): QuestionAnswer? { + val payload = sealed.encryptedPayload ?: return null + val decrypted = runCatching { + sealedRevealManager.decryptPartnerThreadAnswer(coupleId, threadId, sealed.userId, currentUserId, payload) + }.getOrNull() ?: return null + return sealed.copy( + writtenText = decrypted.writtenText, + selectedOptionIds = decrypted.selectedOptionIds, + scaleValue = decrypted.scaleValue + ) + } + // ─── Answer input mutations ────────────────────────────────────────────────── fun updateWrittenText(text: String) { diff --git a/firestore.rules b/firestore.rules index 7ac1fcc4..ff0dd639 100644 --- a/firestore.rules +++ b/firestore.rules @@ -56,6 +56,23 @@ service cloud.firestore { return value is string && value.matches('^enc:v1:[A-Za-z0-9+/]+={0,2}$'); } + // A field is acceptable if it's absent, null, or enc:v1 ciphertext — never plaintext. + function cipherOrAbsent(data, key) { + return !(key in data) || data[key] == null || isCiphertext(data[key]); + } + + // Date plan user content must be ciphertext; dateIdeaId/scheduledDate/status/timestamps + // stay plaintext because Firestore queries and ordering depend on them. + function isDatePlanContentEncrypted(data) { + return cipherOrAbsent(data, 'scheduledTime') + && cipherOrAbsent(data, 'budget') + && cipherOrAbsent(data, 'duration') + && cipherOrAbsent(data, 'activity') + && cipherOrAbsent(data, 'food') + && cipherOrAbsent(data, 'conversationPrompts') + && cipherOrAbsent(data, 'optionalChallenge'); + } + function coupleEncryptionEnabled(coupleId) { return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1; } @@ -411,7 +428,11 @@ service cloud.firestore { && request.resource.data.keys().hasOnly([ 'dateIdeaId', 'preferredDate', 'preferredTime', 'budget', 'duration', 'createdAt', 'updatedAt' - ]); + ]) + // Strict E2EE: user-entered details are ciphertext (dateIdeaId/dates stay plaintext for queries). + && cipherOrAbsent(request.resource.data, 'preferredTime') + && cipherOrAbsent(request.resource.data, 'budget') + && cipherOrAbsent(request.resource.data, 'duration'); allow delete: if false; } @@ -427,7 +448,8 @@ service cloud.firestore { 'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge', 'createdAt', 'updatedAt' ]) - && isValidDatePlanStatus(request.resource.data.status); + && isValidDatePlanStatus(request.resource.data.status) + && isDatePlanContentEncrypted(request.resource.data); allow update: if isCouplesMember(coupleId) // Only the explicitly-listed fields may change on update. // createdAt is intentionally absent — it cannot be modified after creation. @@ -436,7 +458,8 @@ service cloud.firestore { 'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge', 'updatedAt' ]) - && isValidDatePlanStatus(request.resource.data.status); + && isValidDatePlanStatus(request.resource.data.status) + && isDatePlanContentEncrypted(request.resource.data); allow delete: if isCouplesMember(coupleId); }