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.
* 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
* 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(

View File

@ -55,5 +55,6 @@ object FirestoreCollections {
const val ANSWERS = "answers"
const val MESSAGES = "messages"
const val REACTIONS = "reactions"
const val RELEASE_KEYS = "releaseKeys"
}
}

View File

@ -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<String>) ?: 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
)

View File

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

View File

@ -8,5 +8,11 @@ data class QuestionAnswer(
val selectedOptionIds: List<String> = 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
)

View File

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