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 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 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( suspend fun decryptPartnerThreadAnswer(
coupleId: String, coupleId: String,
threadId: String, threadId: String,

View File

@ -8,6 +8,9 @@ import app.closer.crypto.SealedAnswerEncryptor
import app.closer.crypto.UserKeyManager import app.closer.crypto.UserKeyManager
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.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONArray import org.json.JSONArray
import java.time.Clock import java.time.Clock
@ -134,6 +137,24 @@ class FirestoreAnswerDataSource @Inject constructor(
.addOnFailureListener { cont.resumeWithException(it) } .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. * Reads the couple-scoped daily question assignment for today.
*/ */

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.DatePlan import app.closer.domain.model.DatePlan
import app.closer.domain.model.DatePlanPreference import app.closer.domain.model.DatePlanPreference
import app.closer.domain.model.DatePlanStatus import app.closer.domain.model.DatePlanStatus
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 org.json.JSONArray
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
@ -25,7 +28,11 @@ import kotlin.coroutines.resumeWithException
* Local-first: Preferences stored in Room. Plans synced to Firestore when explicitly shared/scheduled. * Local-first: Preferences stored in Room. Plans synced to Firestore when explicitly shared/scheduled.
*/ */
@Singleton @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) = private fun preferencesRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId) db.collection(FirestoreCollections.COUPLES).document(coupleId)
.collection(FirestoreCollections.Couples.DATE_PLAN_PREFERENCES) .collection(FirestoreCollections.Couples.DATE_PLAN_PREFERENCES)
@ -37,13 +44,16 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi
// ─── Preference methods ────────────────────────────────────────────────── // ─── Preference methods ──────────────────────────────────────────────────
suspend fun recordPreference(coupleId: String, preference: DatePlanPreference) { 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 path = preferencesRef(coupleId).document()
val data = mapOf( val data = mapOf(
"dateIdeaId" to preference.dateIdeaId, "dateIdeaId" to preference.dateIdeaId,
"preferredDate" to preference.preferredDate, "preferredDate" to preference.preferredDate,
"preferredTime" to preference.preferredTime, "preferredTime" to fieldEncryptor.encrypt(preference.preferredTime, aead, coupleId),
"budget" to preference.budget, "budget" to fieldEncryptor.encrypt(preference.budget.toString(), aead, coupleId),
"duration" to preference.duration, "duration" to fieldEncryptor.encrypt(preference.duration, aead, coupleId),
"createdAt" to preference.createdAt, "createdAt" to preference.createdAt,
"updatedAt" to preference.updatedAt "updatedAt" to preference.updatedAt
) )
@ -74,40 +84,36 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi
suspend fun createPlan(coupleId: String, plan: DatePlan): String { suspend fun createPlan(coupleId: String, plan: DatePlan): String {
val doc = plansRef(coupleId).document() val doc = plansRef(coupleId).document()
val data = mapOf( doc.set(plan.toEncryptedData(coupleId, includeCreatedAt = true), SetOptions.merge()).voidAwait()
"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()
return doc.id return doc.id
} }
suspend fun updatePlan(coupleId: String, plan: DatePlan) { suspend fun updatePlan(coupleId: String, plan: DatePlan) {
val path = plansRef(coupleId).document(plan.id) val path = plansRef(coupleId).document(plan.id)
val data = mapOf( path.set(plan.toEncryptedData(coupleId, includeCreatedAt = false), SetOptions.merge()).voidAwait()
"dateIdeaId" to plan.dateIdeaId, }
"scheduledDate" to plan.scheduledDate,
"scheduledTime" to plan.scheduledTime, /**
"budget" to plan.budget, * Builds the Firestore payload with user content encrypted. dateIdeaId / scheduledDate /
"duration" to plan.duration, * status / timestamps stay plaintext because Firestore queries and ordering depend on them.
"status" to plan.status.toFirestoreValue(), */
"activity" to plan.activity, private fun DatePlan.toEncryptedData(coupleId: String, includeCreatedAt: Boolean): Map<String, Any?> {
"food" to plan.food, val aead = encryptionManager.requireAead(coupleId)
"conversationPrompts" to plan.conversationPrompts, val data = mutableMapOf<String, Any?>(
"optionalChallenge" to plan.optionalChallenge, "dateIdeaId" to dateIdeaId,
"updatedAt" to plan.updatedAt "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? { suspend fun getPlan(coupleId: String, planId: String): DatePlan? {
@ -163,39 +169,46 @@ class FirestoreDatePlanDataSource @Inject constructor(private val db: FirebaseFi
// ─── Mappers ───────────────────────────────────────────────────────────── // ─── Mappers ─────────────────────────────────────────────────────────────
@Suppress("UNCHECKED_CAST")
private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? { private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlanPreference(coupleId: String): DatePlanPreference? {
val dateIdeaId = getString("dateIdeaId") ?: return null val dateIdeaId = getString("dateIdeaId") ?: return null
val aead = encryptionManager.aeadFor(coupleId)
return DatePlanPreference( return DatePlanPreference(
id = id, id = id,
coupleId = coupleId, coupleId = coupleId,
dateIdeaId = dateIdeaId, dateIdeaId = dateIdeaId,
preferredDate = (get("preferredDate") as? Number)?.toLong() ?: 0L, preferredDate = (get("preferredDate") as? Number)?.toLong() ?: 0L,
preferredTime = getString("preferredTime") ?: "", preferredTime = fieldEncryptor.decryptForDisplay(getString("preferredTime"), aead, coupleId) ?: "",
budget = (get("budget") as? Number)?.toInt() ?: 0, budget = fieldEncryptor.decrypt(getString("budget"), aead, coupleId)?.toIntOrNull() ?: 0,
duration = getString("duration") ?: "", duration = fieldEncryptor.decryptForDisplay(getString("duration"), aead, coupleId) ?: "",
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L, createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
updatedAt = (get("updatedAt") 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? { private fun com.google.firebase.firestore.DocumentSnapshot.toDatePlan(coupleId: String): DatePlan? {
val dateIdeaId = getString("dateIdeaId") ?: return null val dateIdeaId = getString("dateIdeaId") ?: return null
val scheduledDate = (get("scheduledDate") as? Number)?.toLong() ?: 0L 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( return DatePlan(
id = id, id = id,
coupleId = coupleId, coupleId = coupleId,
dateIdeaId = dateIdeaId, dateIdeaId = dateIdeaId,
scheduledDate = scheduledDate, scheduledDate = scheduledDate,
scheduledTime = getString("scheduledTime") ?: "", scheduledTime = fieldEncryptor.decryptForDisplay(getString("scheduledTime"), aead, coupleId) ?: "",
budget = (get("budget") as? Number)?.toInt() ?: 0, budget = fieldEncryptor.decrypt(getString("budget"), aead, coupleId)?.toIntOrNull() ?: 0,
duration = getString("duration") ?: "", duration = fieldEncryptor.decryptForDisplay(getString("duration"), aead, coupleId) ?: "",
status = DatePlanStatus.fromFirestoreValue(getString("status") ?: "draft"), status = DatePlanStatus.fromFirestoreValue(getString("status") ?: "draft"),
activity = getString("activity") ?: "", activity = fieldEncryptor.decryptForDisplay(getString("activity"), aead, coupleId) ?: "",
food = getString("food") ?: "", food = fieldEncryptor.decryptForDisplay(getString("food"), aead, coupleId) ?: "",
conversationPrompts = (get("conversationPrompts") as? List<String>) ?: emptyList(), conversationPrompts = prompts,
optionalChallenge = getString("optionalChallenge"), optionalChallenge = fieldEncryptor.decryptForDisplay(getString("optionalChallenge"), aead, coupleId),
createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L, createdAt = (get("createdAt") as? Number)?.toLong() ?: 0L,
updatedAt = (get("updatedAt") 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 app.closer.domain.repository.QuestionRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -86,6 +87,8 @@ class AnswerRevealViewModel @Inject constructor(
private val _uiState = MutableStateFlow(AnswerRevealUiState()) private val _uiState = MutableStateFlow(AnswerRevealUiState())
val uiState: StateFlow<AnswerRevealUiState> = _uiState.asStateFlow() val uiState: StateFlow<AnswerRevealUiState> = _uiState.asStateFlow()
private var partnerObserveJob: Job? = null
init { init {
load() load()
observeAnswer() observeAnswer()
@ -121,6 +124,12 @@ class AnswerRevealViewModel @Inject constructor(
sealedRevealPhase = sealedPhase, sealedRevealPhase = sealedPhase,
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category) 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) { } catch (e: Exception) {
crashReporter.recordException(e) crashReporter.recordException(e)
_uiState.value = AnswerRevealUiState( _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() { fun refreshPartnerAnswer() {
val state = _uiState.value val state = _uiState.value
val coupleId = state.coupleId ?: return val coupleId = state.coupleId ?: return

View File

@ -3,6 +3,7 @@ package app.closer.ui.questions
import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.crypto.SealedRevealManager
import app.closer.data.local.QuestionDao import app.closer.data.local.QuestionDao
import app.closer.data.local.mapper.toQuestion import app.closer.data.local.mapper.toQuestion
import app.closer.domain.model.ChoiceAnswerConfigImpl import app.closer.domain.model.ChoiceAnswerConfigImpl
@ -46,6 +47,7 @@ data class QuestionThreadUiState(
class QuestionThreadViewModel @Inject constructor( class QuestionThreadViewModel @Inject constructor(
private val repository: QuestionThreadRepository, private val repository: QuestionThreadRepository,
private val questionDao: QuestionDao, private val questionDao: QuestionDao,
private val sealedRevealManager: SealedRevealManager,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@ -53,6 +55,9 @@ class QuestionThreadViewModel @Inject constructor(
private val questionId: String = savedStateHandle["questionId"] ?: "" private val questionId: String = savedStateHandle["questionId"] ?: ""
val currentUserId: String = FirebaseAuth.getInstance().currentUser?.uid ?: "" val currentUserId: String = FirebaseAuth.getInstance().currentUser?.uid ?: ""
// Released-once guard for our thread reveal key.
private var threadKeyReleased = false
private val _uiState = MutableStateFlow( private val _uiState = MutableStateFlow(
QuestionThreadUiState( QuestionThreadUiState(
previousQuestionId = savedStateHandle["prevId"], previousQuestionId = savedStateHandle["prevId"],
@ -81,20 +86,7 @@ class QuestionThreadViewModel @Inject constructor(
launch { launch {
repository.observeAnswers(coupleId, threadId).collect { answers -> repository.observeAnswers(coupleId, threadId).collect { answers ->
val my = answers.find { it.userId == currentUserId } handleAnswers(threadId, answers)
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
)
}
} }
} }
launch { 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 ────────────────────────────────────────────────── // ─── Answer input mutations ──────────────────────────────────────────────────
fun updateWrittenText(text: String) { 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}$'); 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) { function coupleEncryptionEnabled(coupleId) {
return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1; return get(/databases/$(database)/documents/couples/$(coupleId)).data.encryptionVersion >= 1;
} }
@ -411,7 +428,11 @@ service cloud.firestore {
&& request.resource.data.keys().hasOnly([ && request.resource.data.keys().hasOnly([
'dateIdeaId', 'preferredDate', 'preferredTime', 'dateIdeaId', 'preferredDate', 'preferredTime',
'budget', 'duration', 'createdAt', 'updatedAt' '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; allow delete: if false;
} }
@ -427,7 +448,8 @@ service cloud.firestore {
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge', 'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
'createdAt', 'updatedAt' 'createdAt', 'updatedAt'
]) ])
&& isValidDatePlanStatus(request.resource.data.status); && isValidDatePlanStatus(request.resource.data.status)
&& isDatePlanContentEncrypted(request.resource.data);
allow update: if isCouplesMember(coupleId) allow update: if isCouplesMember(coupleId)
// Only the explicitly-listed fields may change on update. // Only the explicitly-listed fields may change on update.
// createdAt is intentionally absent — it cannot be modified after creation. // createdAt is intentionally absent — it cannot be modified after creation.
@ -436,7 +458,8 @@ service cloud.firestore {
'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge', 'status', 'activity', 'food', 'conversationPrompts', 'optionalChallenge',
'updatedAt' 'updatedAt'
]) ])
&& isValidDatePlanStatus(request.resource.data.status); && isValidDatePlanStatus(request.resource.data.status)
&& isDatePlanContentEncrypted(request.resource.data);
allow delete: if isCouplesMember(coupleId); allow delete: if isCouplesMember(coupleId);
} }