From 84eab1825b272ac3ddc532a5a9912192b6d67ac9 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 20 Jun 2026 00:41:48 -0500 Subject: [PATCH] feat: add thread sealed answers, release key cleanup, rules hardening (batch v1.0.16) --- .../app/closer/crypto/SealedRevealManager.kt | 69 +++++++++++++++ .../java/app/closer/crypto/UserKeyManager.kt | 18 +++- .../data/remote/FirestoreCollections.kt | 1 + .../FirestoreQuestionThreadDataSource.kt | 84 ++++++++++++++++++- .../remote/FirestoreReleaseKeyDataSource.kt | 46 ++++++++++ .../app/closer/domain/model/QuestionAnswer.kt | 8 +- firestore.rules | 80 ++++++++++++++++-- 7 files changed, 292 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt index ddff97a4..6e64938e 100644 --- a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt +++ b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt @@ -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. * Safe to call on every launch — no-ops if already published. diff --git a/app/src/main/java/app/closer/crypto/UserKeyManager.kt b/app/src/main/java/app/closer/crypto/UserKeyManager.kt index e36b74dc..afe4b8d5 100644 --- a/app/src/main/java/app/closer/crypto/UserKeyManager.kt +++ b/app/src/main/java/app/closer/crypto/UserKeyManager.kt @@ -23,9 +23,21 @@ import javax.inject.Singleton * [UserKeySetupManager]. Only the public keyset JSON is base64-encoded — no secret * material ever leaves the device. * - * Single-device assumption: we use one keypair per user, not per device. The keypair - * is created once and reused across app restarts. Multi-device support would require - * key distribution across devices (tracked in the multi-device TODO). + * KNOWN LIMITATION — Single-device only: + * One keypair per user, stored only on the device that created it. If a user signs + * 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 class UserKeyManager @Inject constructor( diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index f7175ead..792603cc 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -55,5 +55,6 @@ object FirestoreCollections { const val ANSWERS = "answers" const val MESSAGES = "messages" const val REACTIONS = "reactions" + const val RELEASE_KEYS = "releaseKeys" } } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt index 58352ff5..8c4a589d 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreQuestionThreadDataSource.kt @@ -1,7 +1,11 @@ package app.closer.data.remote +import app.closer.crypto.AnswerCommitment import app.closer.crypto.CoupleEncryptionManager 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.QuestionMessage import app.closer.domain.model.QuestionReaction @@ -25,7 +29,11 @@ import kotlin.coroutines.resumeWithException class FirestoreQuestionThreadDataSource @Inject constructor( private val db: FirebaseFirestore, 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) = @@ -77,6 +85,50 @@ class FirestoreQuestionThreadDataSource @Inject constructor( // ─── Answers ───────────────────────────────────────────────────────────────── 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 aead = encryptionManager.requireAead(coupleId) threadsRef(coupleId) @@ -95,12 +147,23 @@ class FirestoreQuestionThreadDataSource @Inject constructor( "scaleValue" to if (answer.scaleValue != null) fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId) else answer.scaleValue, + "schemaVersion" to 2, "createdAt" to now, "updatedAt" to now ) ).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 { val snap = threadsRef(coupleId) .document(threadId) @@ -224,6 +287,24 @@ class FirestoreQuestionThreadDataSource @Inject constructor( coupleId: String ): QuestionAnswer? { 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) ?: emptyList() val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) { val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId) @@ -250,6 +331,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor( writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId), selectedOptionIds = selectedOptionIds, scaleValue = scaleValue, + schemaVersion = schemaVersion, createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L, updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L ) diff --git a/app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt index 1629a317..42c2f765 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt @@ -57,6 +57,38 @@ class FirestoreReleaseKeyDataSource @Inject constructor( .await() .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( coupleId: String, date: String, @@ -70,4 +102,18 @@ class FirestoreReleaseKeyDataSource @Inject constructor( .document(senderUserId) .collection(FirestoreCollections.Answers.RELEASE_KEYS) .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) } diff --git a/app/src/main/java/app/closer/domain/model/QuestionAnswer.kt b/app/src/main/java/app/closer/domain/model/QuestionAnswer.kt index a7e9251f..46cc9f16 100644 --- a/app/src/main/java/app/closer/domain/model/QuestionAnswer.kt +++ b/app/src/main/java/app/closer/domain/model/QuestionAnswer.kt @@ -8,5 +8,11 @@ data class QuestionAnswer( val selectedOptionIds: List = emptyList(), val scaleValue: Int? = null, 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 ) diff --git a/firestore.rules b/firestore.rules index 433b071a..21c231ee 100644 --- a/firestore.rules +++ b/firestore.rules @@ -106,6 +106,26 @@ service cloud.firestore { .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() { return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0) && request.resource.data.encryptionVersion == 1 @@ -188,10 +208,15 @@ service cloud.firestore { } // Per-user ECIES public keys for sealed-answer key release. - // The owner writes their own public key; couple members may read it to wrap - // release keys. Private key material never appears here. + // The owner writes their own public key; only the user's current partner may read + // 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} { - 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) && request.resource.data.publicKey is string && request.resource.data.publicKey.matches('^pub:v1:') @@ -335,14 +360,46 @@ service cloud.firestore { allow delete: if false; // 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} { - 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 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 @@ -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} { allow read: if isCouplesMember(coupleId) && gameCollection in ['this_or_that', 'desire_sync', 'how_well', 'wheel'];