feat(e2ee): encrypt date plan content; live answer observation; own-thread-answer decrypt; strict rules

This commit is contained in:
null 2026-06-23 17:47:07 -05:00
parent 039752d691
commit d4b20a9845
6 changed files with 234 additions and 62 deletions

View File

@ -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,

View File

@ -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<LocalAnswer?> = 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.
*/

View File

@ -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<String, Any?> {
val aead = encryptionManager.requireAead(coupleId)
val data = mutableMapOf<String, Any?>(
"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<String>) ?: 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
)

View File

@ -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<AnswerRevealUiState> = _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

View File

@ -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<QuestionAnswer>) {
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) {

View File

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