feat: add thread sealed answers, release key cleanup, rules hardening (batch v1.0.16)

This commit is contained in:
null 2026-06-20 00:41:48 -05:00
parent 4900d8ab6b
commit 84eab1825b
7 changed files with 292 additions and 14 deletions

View File

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

View File

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

View File

@ -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"
} }
} }

View File

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

View File

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

View File

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

View File

@ -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'];