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.
|
||||
* 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
|
||||
* 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(
|
||||
|
|
|
|||
|
|
@ -55,5 +55,6 @@ object FirestoreCollections {
|
|||
const val ANSWERS = "answers"
|
||||
const val MESSAGES = "messages"
|
||||
const val REACTIONS = "reactions"
|
||||
const val RELEASE_KEYS = "releaseKeys"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
Loading…
Reference in New Issue