feat: add thread sealed answers, release key cleanup, rules hardening (batch v1.0.16)
This commit is contained in:
parent
4900d8ab6b
commit
84eab1825b
|
|
@ -108,6 +108,75 @@ class SealedRevealManager @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Thread reveal ─────────────────────────────────────────────────────────────
|
||||||
|
// Thread answers use the threadId (not a date) as the key-store identifier and
|
||||||
|
// as the AAD "questionId", so daily-question and thread keys for the same
|
||||||
|
// question ID are always cryptographically distinct.
|
||||||
|
|
||||||
|
suspend fun releaseOwnKeyForThread(
|
||||||
|
coupleId: String,
|
||||||
|
threadId: String,
|
||||||
|
userId: String,
|
||||||
|
partnerId: String
|
||||||
|
): Boolean {
|
||||||
|
val storeKey = "thread_$threadId"
|
||||||
|
val oneTimeKey = pendingAnswerKeyStore.load(storeKey) ?: return false
|
||||||
|
val partnerPublicKey = deviceKeyDataSource.getPublicKey(partnerId) ?: return false
|
||||||
|
|
||||||
|
val keybox = releaseKeyEncryptor.wrapForRecipient(
|
||||||
|
oneTimeKey = oneTimeKey,
|
||||||
|
recipientPublicKeyB64 = partnerPublicKey,
|
||||||
|
coupleId = coupleId,
|
||||||
|
questionId = threadId,
|
||||||
|
senderUserId = userId,
|
||||||
|
recipientUserId = partnerId
|
||||||
|
)
|
||||||
|
|
||||||
|
releaseKeyDataSource.writeReleaseKeyForThread(
|
||||||
|
coupleId = coupleId,
|
||||||
|
threadId = threadId,
|
||||||
|
senderUserId = userId,
|
||||||
|
recipientUserId = partnerId,
|
||||||
|
encryptedAnswerKey = keybox
|
||||||
|
)
|
||||||
|
|
||||||
|
pendingAnswerKeyStore.remove(storeKey)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun decryptPartnerThreadAnswer(
|
||||||
|
coupleId: String,
|
||||||
|
threadId: String,
|
||||||
|
partnerId: String,
|
||||||
|
userId: String,
|
||||||
|
encryptedPayload: String
|
||||||
|
): SealedAnswerEncryptor.AnswerPayload? {
|
||||||
|
val keybox = releaseKeyDataSource.readReleaseKeyForThread(
|
||||||
|
coupleId = coupleId,
|
||||||
|
threadId = threadId,
|
||||||
|
senderUserId = partnerId,
|
||||||
|
recipientUserId = userId
|
||||||
|
) ?: return null
|
||||||
|
|
||||||
|
val myPrivateKey = userKeyManager.getOrCreatePrivateKey()
|
||||||
|
val oneTimeKey = releaseKeyEncryptor.unwrapFromSender(
|
||||||
|
keyboxB64 = keybox,
|
||||||
|
recipientPrivateKey = myPrivateKey,
|
||||||
|
coupleId = coupleId,
|
||||||
|
questionId = threadId,
|
||||||
|
senderUserId = partnerId,
|
||||||
|
recipientUserId = userId
|
||||||
|
)
|
||||||
|
|
||||||
|
return sealedAnswerEncryptor.open(
|
||||||
|
encryptedPayload = encryptedPayload,
|
||||||
|
keyHandle = oneTimeKey,
|
||||||
|
coupleId = coupleId,
|
||||||
|
questionId = threadId,
|
||||||
|
userId = partnerId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensures this user's public key is published to Firestore.
|
* Ensures this user's public key is published to Firestore.
|
||||||
* Safe to call on every launch — no-ops if already published.
|
* Safe to call on every launch — no-ops if already published.
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,21 @@ import javax.inject.Singleton
|
||||||
* [UserKeySetupManager]. Only the public keyset JSON is base64-encoded — no secret
|
* [UserKeySetupManager]. Only the public keyset JSON is base64-encoded — no secret
|
||||||
* material ever leaves the device.
|
* material ever leaves the device.
|
||||||
*
|
*
|
||||||
* Single-device assumption: we use one keypair per user, not per device. The keypair
|
* KNOWN LIMITATION — Single-device only:
|
||||||
* is created once and reused across app restarts. Multi-device support would require
|
* One keypair per user, stored only on the device that created it. If a user signs
|
||||||
* key distribution across devices (tracked in the multi-device TODO).
|
* in on a second device, that device generates a NEW keypair and overwrites
|
||||||
|
* users/{uid}/devices/primary with the new public key. Any sealed answers whose
|
||||||
|
* one-time keys were wrapped for the OLD public key become permanently undecryptable
|
||||||
|
* on both devices (partner encrypted to stale key; private key is on original device).
|
||||||
|
*
|
||||||
|
* Symptoms: "This answer cannot be revealed from this device" on the new device,
|
||||||
|
* and the original device can no longer complete the reveal (its pending key was
|
||||||
|
* removed after key release, but the partner encrypted to the new key).
|
||||||
|
*
|
||||||
|
* Fix path (not implemented): multi-device key distribution, e.g. key agreement
|
||||||
|
* via iCloud/Google Drive backup or a per-device public key stored under
|
||||||
|
* users/{uid}/devices/{deviceId} so partners encrypt to ALL of the user's known
|
||||||
|
* public keys simultaneously.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
@Singleton
|
||||||
class UserKeyManager @Inject constructor(
|
class UserKeyManager @Inject constructor(
|
||||||
|
|
|
||||||
|
|
@ -55,5 +55,6 @@ object FirestoreCollections {
|
||||||
const val ANSWERS = "answers"
|
const val ANSWERS = "answers"
|
||||||
const val MESSAGES = "messages"
|
const val MESSAGES = "messages"
|
||||||
const val REACTIONS = "reactions"
|
const val REACTIONS = "reactions"
|
||||||
|
const val RELEASE_KEYS = "releaseKeys"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.AnswerCommitment
|
||||||
import app.closer.crypto.CoupleEncryptionManager
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
import app.closer.crypto.FieldEncryptor
|
import app.closer.crypto.FieldEncryptor
|
||||||
|
import app.closer.crypto.PendingAnswerKeyStore
|
||||||
|
import app.closer.crypto.SealedAnswerEncryptor
|
||||||
|
import app.closer.crypto.UserKeyManager
|
||||||
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
|
||||||
|
|
@ -25,7 +29,11 @@ import kotlin.coroutines.resumeWithException
|
||||||
class FirestoreQuestionThreadDataSource @Inject constructor(
|
class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
private val db: FirebaseFirestore,
|
private val db: FirebaseFirestore,
|
||||||
private val encryptionManager: CoupleEncryptionManager,
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
private val fieldEncryptor: FieldEncryptor
|
private val fieldEncryptor: FieldEncryptor,
|
||||||
|
private val userKeyManager: UserKeyManager,
|
||||||
|
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
|
||||||
|
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
||||||
|
private val answerCommitment: AnswerCommitment
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private fun threadsRef(coupleId: String) =
|
private fun threadsRef(coupleId: String) =
|
||||||
|
|
@ -77,6 +85,50 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
// ─── Answers ─────────────────────────────────────────────────────────────────
|
// ─── Answers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
||||||
|
if (userKeyManager.loadPrivateKey() != null) {
|
||||||
|
submitAnswerSealed(coupleId, threadId, userId, answer)
|
||||||
|
} else {
|
||||||
|
submitAnswerEncrypted(coupleId, threadId, userId, answer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaVersion 3: per-answer one-time key — partner-proof before reveal.
|
||||||
|
// threadId is used as the AAD "questionId" so thread keys are distinct from
|
||||||
|
// daily-question keys even when the same question appears in both contexts.
|
||||||
|
private suspend fun submitAnswerSealed(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
||||||
|
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
|
||||||
|
val payload = SealedAnswerEncryptor.AnswerPayload(
|
||||||
|
writtenText = answer.writtenText,
|
||||||
|
selectedOptionIds = answer.selectedOptionIds,
|
||||||
|
scaleValue = answer.scaleValue
|
||||||
|
)
|
||||||
|
val encryptedPayload = sealedAnswerEncryptor.seal(payload, oneTimeKey, coupleId, threadId, userId)
|
||||||
|
val commitment = answerCommitment.compute(
|
||||||
|
coupleId, threadId, userId,
|
||||||
|
answer.writtenText, answer.selectedOptionIds, answer.scaleValue
|
||||||
|
)
|
||||||
|
pendingAnswerKeyStore.store("thread_$threadId", oneTimeKey)
|
||||||
|
|
||||||
|
threadsRef(coupleId).document(threadId)
|
||||||
|
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
||||||
|
.document(userId)
|
||||||
|
.set(
|
||||||
|
mapOf(
|
||||||
|
"userId" to userId,
|
||||||
|
"questionId" to answer.questionId,
|
||||||
|
"answerType" to answer.answerType,
|
||||||
|
"encryptedPayload" to encryptedPayload,
|
||||||
|
"commitmentHash" to commitment,
|
||||||
|
"schemaVersion" to 3,
|
||||||
|
"answerKeyReleased" to false,
|
||||||
|
"createdAt" to FieldValue.serverTimestamp(),
|
||||||
|
"updatedAt" to FieldValue.serverTimestamp()
|
||||||
|
)
|
||||||
|
).voidAwait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaVersion 2: shared couple key (company-proof, not partner-proof).
|
||||||
|
private suspend fun submitAnswerEncrypted(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
|
||||||
val now = FieldValue.serverTimestamp()
|
val now = FieldValue.serverTimestamp()
|
||||||
val aead = encryptionManager.requireAead(coupleId)
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
threadsRef(coupleId)
|
threadsRef(coupleId)
|
||||||
|
|
@ -95,12 +147,23 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
"scaleValue" to if (answer.scaleValue != null)
|
"scaleValue" to if (answer.scaleValue != null)
|
||||||
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
||||||
else answer.scaleValue,
|
else answer.scaleValue,
|
||||||
|
"schemaVersion" to 2,
|
||||||
"createdAt" to now,
|
"createdAt" to now,
|
||||||
"updatedAt" to now
|
"updatedAt" to now
|
||||||
)
|
)
|
||||||
).voidAwait()
|
).voidAwait()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Call after releasing the one-time key so the answer doc reflects the released state.
|
||||||
|
// Required for correct phase detection on cold restart of the reveal screen.
|
||||||
|
suspend fun markAnswerKeyReleased(coupleId: String, threadId: String, userId: String) {
|
||||||
|
threadsRef(coupleId).document(threadId)
|
||||||
|
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
||||||
|
.document(userId)
|
||||||
|
.update(mapOf("answerKeyReleased" to true, "updatedAt" to FieldValue.serverTimestamp()))
|
||||||
|
.voidAwait()
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getAnswerCount(coupleId: String, threadId: String): Int {
|
suspend fun getAnswerCount(coupleId: String, threadId: String): Int {
|
||||||
val snap = threadsRef(coupleId)
|
val snap = threadsRef(coupleId)
|
||||||
.document(threadId)
|
.document(threadId)
|
||||||
|
|
@ -224,6 +287,24 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
coupleId: String
|
coupleId: String
|
||||||
): QuestionAnswer? {
|
): QuestionAnswer? {
|
||||||
val userId = getString("userId") ?: return null
|
val userId = getString("userId") ?: return null
|
||||||
|
val schemaVersion = getLong("schemaVersion")?.toInt() ?: 2
|
||||||
|
|
||||||
|
// schemaVersion 3: sealed:v1: — content is in encryptedPayload.
|
||||||
|
// Decryption requires the partner's release key; the reveal flow handles it.
|
||||||
|
if (schemaVersion == 3) {
|
||||||
|
return QuestionAnswer(
|
||||||
|
userId = userId,
|
||||||
|
questionId = getString("questionId") ?: "",
|
||||||
|
answerType = getString("answerType") ?: "written",
|
||||||
|
schemaVersion = 3,
|
||||||
|
isSealed = getBoolean("answerKeyReleased") != true,
|
||||||
|
encryptedPayload = getString("encryptedPayload"),
|
||||||
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
|
||||||
|
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaVersion 2: enc:v1: — decrypt with couple AEAD.
|
||||||
val rawIds = (get("selectedOptionIds") as? List<String>) ?: emptyList()
|
val rawIds = (get("selectedOptionIds") as? List<String>) ?: emptyList()
|
||||||
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
|
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
|
||||||
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
|
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
|
||||||
|
|
@ -250,6 +331,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(
|
||||||
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
|
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
|
||||||
selectedOptionIds = selectedOptionIds,
|
selectedOptionIds = selectedOptionIds,
|
||||||
scaleValue = scaleValue,
|
scaleValue = scaleValue,
|
||||||
|
schemaVersion = schemaVersion,
|
||||||
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
|
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
|
||||||
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,38 @@ class FirestoreReleaseKeyDataSource @Inject constructor(
|
||||||
.await()
|
.await()
|
||||||
.getString("encryptedAnswerKey")
|
.getString("encryptedAnswerKey")
|
||||||
|
|
||||||
|
// ── Thread release keys ───────────────────────────────────────────────────────
|
||||||
|
// Path: couples/{coupleId}/question_threads/{threadId}/answers/{senderUserId}/releaseKeys/{recipientUserId}
|
||||||
|
|
||||||
|
suspend fun writeReleaseKeyForThread(
|
||||||
|
coupleId: String,
|
||||||
|
threadId: String,
|
||||||
|
senderUserId: String,
|
||||||
|
recipientUserId: String,
|
||||||
|
encryptedAnswerKey: String
|
||||||
|
) {
|
||||||
|
val ref = threadReleaseKeyRef(coupleId, threadId, senderUserId, recipientUserId)
|
||||||
|
if (ref.get().await().exists()) return
|
||||||
|
ref.set(
|
||||||
|
mapOf(
|
||||||
|
"recipientUserId" to recipientUserId,
|
||||||
|
"encryptedAnswerKey" to encryptedAnswerKey,
|
||||||
|
"releasedAt" to System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
).await()
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun readReleaseKeyForThread(
|
||||||
|
coupleId: String,
|
||||||
|
threadId: String,
|
||||||
|
senderUserId: String,
|
||||||
|
recipientUserId: String
|
||||||
|
): String? =
|
||||||
|
threadReleaseKeyRef(coupleId, threadId, senderUserId, recipientUserId)
|
||||||
|
.get()
|
||||||
|
.await()
|
||||||
|
.getString("encryptedAnswerKey")
|
||||||
|
|
||||||
private fun releaseKeyRef(
|
private fun releaseKeyRef(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
date: String,
|
date: String,
|
||||||
|
|
@ -70,4 +102,18 @@ class FirestoreReleaseKeyDataSource @Inject constructor(
|
||||||
.document(senderUserId)
|
.document(senderUserId)
|
||||||
.collection(FirestoreCollections.Answers.RELEASE_KEYS)
|
.collection(FirestoreCollections.Answers.RELEASE_KEYS)
|
||||||
.document(recipientUserId)
|
.document(recipientUserId)
|
||||||
|
|
||||||
|
private fun threadReleaseKeyRef(
|
||||||
|
coupleId: String,
|
||||||
|
threadId: String,
|
||||||
|
senderUserId: String,
|
||||||
|
recipientUserId: String
|
||||||
|
) = db.collection(FirestoreCollections.COUPLES)
|
||||||
|
.document(coupleId)
|
||||||
|
.collection(FirestoreCollections.Couples.QUESTION_THREADS)
|
||||||
|
.document(threadId)
|
||||||
|
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
|
||||||
|
.document(senderUserId)
|
||||||
|
.collection(FirestoreCollections.QuestionThreads.RELEASE_KEYS)
|
||||||
|
.document(recipientUserId)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,11 @@ data class QuestionAnswer(
|
||||||
val selectedOptionIds: List<String> = emptyList(),
|
val selectedOptionIds: List<String> = emptyList(),
|
||||||
val scaleValue: Int? = null,
|
val scaleValue: Int? = null,
|
||||||
val createdAt: Long = 0L,
|
val createdAt: Long = 0L,
|
||||||
val updatedAt: Long = 0L
|
val updatedAt: Long = 0L,
|
||||||
|
// schemaVersion 2 = enc:v1: (couple-key); 3 = sealed:v1: (partner-proof one-time key)
|
||||||
|
val schemaVersion: Int = 2,
|
||||||
|
// true when a sealed answer key has not yet been released to the partner
|
||||||
|
val isSealed: Boolean = false,
|
||||||
|
// raw sealed:v1: payload; populated for schemaVersion 3, null otherwise
|
||||||
|
val encryptedPayload: String? = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,26 @@ service cloud.firestore {
|
||||||
.hasOnly(['isRevealed', 'answerKeyReleased', 'updatedAt']);
|
.hasOnly(['isRevealed', 'answerKeyReleased', 'updatedAt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Thread sealed answers differ from daily answers: no answerDate (threads use threadId
|
||||||
|
// as context), no isRevealed field (reveal state is tracked by the thread VM).
|
||||||
|
function isSealedThreadAnswerCreate(data) {
|
||||||
|
return data.keys().hasOnly([
|
||||||
|
'userId', 'questionId', 'answerType', 'encryptedPayload',
|
||||||
|
'commitmentHash', 'schemaVersion', 'answerKeyReleased',
|
||||||
|
'createdAt', 'updatedAt'
|
||||||
|
])
|
||||||
|
&& isSealedPayload(data.encryptedPayload)
|
||||||
|
&& isCommitmentHash(data.commitmentHash)
|
||||||
|
&& data.schemaVersion == 3
|
||||||
|
&& data.answerKeyReleased == false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSealedThreadAnswerUpdate() {
|
||||||
|
return resource.data.schemaVersion == 3
|
||||||
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
.hasOnly(['answerKeyReleased', 'updatedAt']);
|
||||||
|
}
|
||||||
|
|
||||||
function isStartingEncryptionMigration() {
|
function isStartingEncryptionMigration() {
|
||||||
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
|
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
|
||||||
&& request.resource.data.encryptionVersion == 1
|
&& request.resource.data.encryptionVersion == 1
|
||||||
|
|
@ -188,10 +208,15 @@ service cloud.firestore {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user ECIES public keys for sealed-answer key release.
|
// Per-user ECIES public keys for sealed-answer key release.
|
||||||
// The owner writes their own public key; couple members may read it to wrap
|
// The owner writes their own public key; only the user's current partner may read
|
||||||
// release keys. Private key material never appears here.
|
// it (to wrap release keys). Restricting to couple members prevents a malicious
|
||||||
|
// user from reading arbitrary public keys and pre-encrypting speculative release keys.
|
||||||
match /devices/{deviceId} {
|
match /devices/{deviceId} {
|
||||||
allow read: if isSignedIn();
|
allow read: if isOwner(uid)
|
||||||
|
|| (isSignedIn()
|
||||||
|
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId != null
|
||||||
|
&& get(/databases/$(database)/documents/users/$(uid)).data.coupleId
|
||||||
|
== get(/databases/$(database)/documents/users/$(request.auth.uid)).data.coupleId);
|
||||||
allow create, update: if isOwner(uid)
|
allow create, update: if isOwner(uid)
|
||||||
&& request.resource.data.publicKey is string
|
&& request.resource.data.publicKey is string
|
||||||
&& request.resource.data.publicKey.matches('^pub:v1:')
|
&& request.resource.data.publicKey.matches('^pub:v1:')
|
||||||
|
|
@ -335,14 +360,46 @@ service cloud.firestore {
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
|
|
||||||
// Answers: each user writes their own; both members can read all answers.
|
// Answers: each user writes their own; both members can read all answers.
|
||||||
|
// Accepts schemaVersion 3 (sealed:v1: partner-proof) or schemaVersion 2 (enc:v1: couple-key).
|
||||||
match /answers/{userId} {
|
match /answers/{userId} {
|
||||||
allow create, update: if isCouplesMember(coupleId)
|
|
||||||
&& isOwner(userId)
|
|
||||||
&& request.resource.data.keys().hasOnly(['userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'createdAt', 'updatedAt'])
|
|
||||||
&& coupleEncryptionEnabled(coupleId)
|
|
||||||
&& isEncryptedAnswerPayload(request.resource.data);
|
|
||||||
allow delete: if isOwner(userId);
|
|
||||||
allow read: if isCouplesMember(coupleId);
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
allow delete: if isOwner(userId);
|
||||||
|
allow create: if isCouplesMember(coupleId)
|
||||||
|
&& isOwner(userId)
|
||||||
|
&& coupleEncryptionEnabled(coupleId)
|
||||||
|
&& (
|
||||||
|
isSealedThreadAnswerCreate(request.resource.data)
|
||||||
|
|| (request.resource.data.schemaVersion == 2
|
||||||
|
&& request.resource.data.keys().hasOnly([
|
||||||
|
'userId', 'questionId', 'answerType', 'writtenText',
|
||||||
|
'selectedOptionIds', 'scaleValue', 'schemaVersion',
|
||||||
|
'createdAt', 'updatedAt'
|
||||||
|
])
|
||||||
|
&& isEncryptedAnswerPayload(request.resource.data))
|
||||||
|
);
|
||||||
|
allow update: if isCouplesMember(coupleId)
|
||||||
|
&& isOwner(userId)
|
||||||
|
&& (
|
||||||
|
isSealedThreadAnswerUpdate()
|
||||||
|
|| (coupleEncryptionEnabled(coupleId)
|
||||||
|
&& resource.data.schemaVersion != 3
|
||||||
|
&& request.resource.data.keys().hasOnly([
|
||||||
|
'userId', 'questionId', 'answerType', 'writtenText',
|
||||||
|
'selectedOptionIds', 'scaleValue', 'schemaVersion',
|
||||||
|
'createdAt', 'updatedAt'
|
||||||
|
])
|
||||||
|
&& isEncryptedAnswerPayload(request.resource.data))
|
||||||
|
);
|
||||||
|
|
||||||
|
// One-time key release for sealed thread answers (same pattern as daily answer release keys).
|
||||||
|
match /releaseKeys/{recipientId} {
|
||||||
|
allow read: if isCouplesMember(coupleId) && request.auth.uid == recipientId;
|
||||||
|
allow create: if isCouplesMember(coupleId)
|
||||||
|
&& request.auth.uid == userId
|
||||||
|
&& isKeybox(request.resource.data.encryptedAnswerKey)
|
||||||
|
&& request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
|
||||||
|
allow update, delete: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discussion messages: any couple member can read, but only the author can write/update/delete
|
// Discussion messages: any couple member can read, but only the author can write/update/delete
|
||||||
|
|
@ -540,6 +597,11 @@ service cloud.firestore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Games use enc:v1: (schemaVersion 2 / shared couple key).
|
||||||
|
// They are company-proof but not partner-proof: a modified client could read the
|
||||||
|
// partner's encrypted slot before the reveal screen. Sealed per-answer keys are not
|
||||||
|
// used here because games are real-time simultaneous — both players submit and see
|
||||||
|
// results together; there is no single async "reveal" event to gate on.
|
||||||
match /{gameCollection}/{sessionId} {
|
match /{gameCollection}/{sessionId} {
|
||||||
allow read: if isCouplesMember(coupleId)
|
allow read: if isCouplesMember(coupleId)
|
||||||
&& gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel'];
|
&& gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel'];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue