feat(e2ee): encrypt date plan content; live answer observation; own-thread-answer decrypt; strict rules
This commit is contained in:
parent
039752d691
commit
d4b20a9845
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue