feat: implement partner-proof sealed answers (batches 1-8)

- UserKeyManager: per-user keypair stored in Android Keystore
- SealedAnswerEncryptor: one-time answer key, sealed:v1 ciphertext
- PendingAnswerKeyStore: local EncryptedSharedPreferences storage
- ReleaseKeyEncryptor: keybox:v1 encrypted to recipient public key
- SealedRevealManager: full reveal flow with mutual key release
- AnswerCommitment: SHA-256 commitment hash over canonical payload
- FirestoreDeviceKeyDataSource: public key CRUD
- FirestoreReleaseKeyDataSource: release key CRUD
- FirestoreAnswerDataSource: sealed answer writes with schemaVersion=3
- FirestoreCollections: sealed answer and release key paths
- firestore.rules: ownership, immutability, timing, prefix enforcement
- HomeViewModel: sealed answer state integration
- AnswerRevealScreen/ViewModel: sealed reveal flow with UX states
- CloserApp: initialize UserKeyManager on startup
- LocalAnswer model: schemaVersion field
- Unit tests: SealedAnswerEncryptor, ReleaseKeyEncryptor, AnswerCommitment
- Crypto test vectors: docs/crypto/sealed-answer-test-vectors.json
- .gitignore: add partner-proof build plan
This commit is contained in:
null 2026-06-20 00:23:58 -05:00
parent 521989ec44
commit a3993d08df
21 changed files with 1591 additions and 27 deletions

1
.gitignore vendored
View File

@ -50,3 +50,4 @@ UI-PLAN.md
# Build plans (agent-only, never commit) # Build plans (agent-only, never commit)
*_build_plan.md *_build_plan.md
closer_partner_proof_reveal_privacy.md

View File

@ -6,6 +6,7 @@ import app.closer.data.repository.ActivityProvider
import app.closer.domain.security.DeviceIntegrityChecker import app.closer.domain.security.DeviceIntegrityChecker
import app.closer.notifications.NotificationChannelSetup import app.closer.notifications.NotificationChannelSetup
import com.google.crypto.tink.aead.AeadConfig import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.hybrid.HybridConfig
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -24,6 +25,7 @@ class CloserApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
AeadConfig.register() AeadConfig.register()
HybridConfig.register()
ActivityProvider.register(this) ActivityProvider.register(this)
NotificationChannelSetup.createChannels(applicationContext) NotificationChannelSetup.createChannels(applicationContext)
firebaseInitializer.initialize() firebaseInitializer.initialize()

View File

@ -0,0 +1,68 @@
package app.closer.crypto
import java.security.MessageDigest
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Computes a SHA-256 commitment hash over the answer payload at submit time.
*
* The hash binds the plaintext content to the couple, question, and user so that
* swapping or mutating the answer after submission is detectable.
*
* Input: "v1|{coupleId}|{questionId}|{userId}|{canonicalJson}"
* Canonical JSON has fixed key order (scaleValue, selectedOptionIds, writtenText)
* and sorted selectedOptionIds, so the hash is stable across serialisations.
*
* Wire format: "sha256:{urlsafe-base64-no-padding}"
*/
@Singleton
class AnswerCommitment @Inject constructor() {
fun compute(
coupleId: String,
questionId: String,
userId: String,
writtenText: String?,
selectedOptionIds: List<String>,
scaleValue: Int?
): String {
val canonical = canonical(writtenText, selectedOptionIds, scaleValue)
val input = "v1|$coupleId|$questionId|$userId|$canonical"
val hash = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray(Charsets.UTF_8))
return "sha256:${Base64.getUrlEncoder().withoutPadding().encodeToString(hash)}"
}
/**
* Produces a stable JSON string with alphabetically ordered keys and
* sorted selectedOptionIds. Never uses Android's JSONObject so the output
* is identical on any JVM platform (required for iOS cross-validation).
*/
internal fun canonical(
writtenText: String?,
selectedOptionIds: List<String>,
scaleValue: Int?
): String {
val ids = selectedOptionIds.sorted().joinToString(",") { "\"${escape(it)}\"" }
val text = if (writtenText != null) "\"${escape(writtenText)}\"" else "null"
val scale = scaleValue?.toString() ?: "null"
return "{\"scaleValue\":$scale,\"selectedOptionIds\":[$ids],\"writtenText\":$text}"
}
private fun escape(s: String): String = buildString {
for (c in s) when (c) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(c)
}
}
companion object {
const val PREFIX = "sha256:"
}
}

View File

@ -0,0 +1,48 @@
package app.closer.crypto
import android.content.Context
import app.closer.data.local.SecurePreferencesFactory
import com.google.crypto.tink.KeysetHandle
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists one-time answer keys locally (EncryptedSharedPreferences / Keystore-backed)
* from the moment the user submits a sealed answer until the reveal key is released.
*
* Keys are keyed by questionId. After release the key is removed to prevent stale keys
* from accumulating. If the user loses the device before releasing their key, the answer
* cannot be revealed from this device (by design the crypto is only as strong as the
* local key store).
*/
@Singleton
class PendingAnswerKeyStore @Inject constructor(
@ApplicationContext context: Context,
private val sealedAnswerEncryptor: SealedAnswerEncryptor
) {
private val prefs = SecurePreferencesFactory.encryptedSharedPreferences(context, PREFS_NAME)
fun store(questionId: String, keyHandle: KeysetHandle) {
prefs.edit()
.putString(prefKey(questionId), sealedAnswerEncryptor.serializeKey(keyHandle))
.apply()
}
fun load(questionId: String): KeysetHandle? = runCatching {
val json = prefs.getString(prefKey(questionId), null) ?: return null
sealedAnswerEncryptor.deserializeKey(json)
}.getOrNull()
fun remove(questionId: String) {
prefs.edit().remove(prefKey(questionId)).apply()
}
fun hasPendingKey(questionId: String): Boolean = prefs.contains(prefKey(questionId))
private fun prefKey(questionId: String) = "pending_answer_key_$questionId"
private companion object {
const val PREFS_NAME = "pending_answer_keys_secure"
}
}

View File

@ -0,0 +1,70 @@
package app.closer.crypto
import com.google.crypto.tink.KeysetHandle
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Wraps and unwraps a one-time answer key for secure transfer between partners at reveal time.
*
* At reveal (both partners have submitted):
* Alice calls [wrapForRecipient] with Bob's public key writes "keybox:v1:..." to Firestore.
* Bob calls [unwrapFromSender] with his own private key recovers Alice's one-time answer key.
* Bob's app can then decrypt Alice's sealed answer payload.
*
* Context info (ECIES HKDF label) = "{coupleId}|{questionId}|{senderUserId}|{recipientUserId}",
* binding the wrapped key to its exact origin and destination.
*
* Wire format: "keybox:v1:{urlsafe-base64-no-padding}"
*
* The Tink primitive operations are called via [UserKeyManager] companion object functions
* so this class has no dependency on Android Context and remains unit-testable.
*/
@Singleton
class ReleaseKeyEncryptor @Inject constructor(
private val sealedAnswerEncryptor: SealedAnswerEncryptor
) {
fun wrapForRecipient(
oneTimeKey: KeysetHandle,
recipientPublicKeyB64: String,
coupleId: String,
questionId: String,
senderUserId: String,
recipientUserId: String
): String {
val keyBytes = sealedAnswerEncryptor.serializeKey(oneTimeKey).toByteArray(Charsets.UTF_8)
val contextInfo = contextInfo(coupleId, questionId, senderUserId, recipientUserId)
val hybrid = UserKeyManager.hybridEncryptFrom(recipientPublicKeyB64)
val ciphertext = hybrid.encrypt(keyBytes, contextInfo)
return KEYBOX_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext)
}
fun unwrapFromSender(
keyboxB64: String,
recipientPrivateKey: KeysetHandle,
coupleId: String,
questionId: String,
senderUserId: String,
recipientUserId: String
): KeysetHandle {
require(keyboxB64.startsWith(KEYBOX_PREFIX)) { "Not a keybox payload" }
val ciphertext = Base64.getUrlDecoder().decode(keyboxB64.removePrefix(KEYBOX_PREFIX))
val contextInfo = contextInfo(coupleId, questionId, senderUserId, recipientUserId)
val hybrid = UserKeyManager.hybridDecryptFor(recipientPrivateKey)
val keyJson = hybrid.decrypt(ciphertext, contextInfo).toString(Charsets.UTF_8)
return sealedAnswerEncryptor.deserializeKey(keyJson)
}
private fun contextInfo(
coupleId: String,
questionId: String,
senderUserId: String,
recipientUserId: String
): ByteArray = "$coupleId|$questionId|$senderUserId|$recipientUserId".toByteArray(Charsets.UTF_8)
companion object {
const val KEYBOX_PREFIX = "keybox:v1:"
}
}

View File

@ -0,0 +1,143 @@
package app.closer.crypto
import com.google.crypto.tink.Aead
import com.google.crypto.tink.CleartextKeysetHandle
import com.google.crypto.tink.JsonKeysetReader
import com.google.crypto.tink.JsonKeysetWriter
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.aead.AeadKeyTemplates
import java.io.ByteArrayOutputStream
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Encrypts and decrypts sealed answer payloads using a per-answer one-time AES-256-GCM key.
*
* The payload (writtenText, selectedOptionIds, scaleValue) is bundled as canonical JSON and
* encrypted with Tink AEAD. The result is prefixed "sealed:v1:" to distinguish sealed docs
* from legacy "enc:v1:" couple-key encrypted docs.
*
* AAD = "{coupleId}|{questionId}|{userId}" binds the ciphertext to its origin so a
* ciphertext cannot be transplanted to a different user or question.
*
* The one-time key itself never leaves the device before reveal. It is kept in
* [PendingAnswerKeyStore] and only released (wrapped with the partner's public key) after
* both partners have submitted.
*/
@Singleton
class SealedAnswerEncryptor @Inject constructor() {
data class AnswerPayload(
val writtenText: String?,
val selectedOptionIds: List<String>,
val scaleValue: Int?
)
fun generateOneTimeKey(): KeysetHandle =
KeysetHandle.generateNew(AeadKeyTemplates.AES256_GCM)
/**
* Encrypts [payload] and returns the "sealed:v1:..." ciphertext string.
*/
fun seal(
payload: AnswerPayload,
keyHandle: KeysetHandle,
coupleId: String,
questionId: String,
userId: String
): String {
val aead = keyHandle.getPrimitive(Aead::class.java)
val plaintext = encodePayload(payload)
val ciphertext = aead.encrypt(plaintext, aad(coupleId, questionId, userId))
return SEALED_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(ciphertext)
}
/**
* Decrypts a "sealed:v1:..." string back to [AnswerPayload].
* Throws if the ciphertext is corrupt or the AAD does not match.
*/
fun open(
encryptedPayload: String,
keyHandle: KeysetHandle,
coupleId: String,
questionId: String,
userId: String
): AnswerPayload {
require(encryptedPayload.startsWith(SEALED_PREFIX)) { "Not a sealed payload" }
val ciphertext = Base64.getUrlDecoder().decode(encryptedPayload.removePrefix(SEALED_PREFIX))
val aead = keyHandle.getPrimitive(Aead::class.java)
val plaintext = aead.decrypt(ciphertext, aad(coupleId, questionId, userId))
return decodePayload(plaintext)
}
fun serializeKey(keyHandle: KeysetHandle): String {
val baos = ByteArrayOutputStream()
CleartextKeysetHandle.write(keyHandle, JsonKeysetWriter.withOutputStream(baos))
return baos.toString(Charsets.UTF_8.name())
}
fun deserializeKey(json: String): KeysetHandle =
CleartextKeysetHandle.read(JsonKeysetReader.withString(json))
private fun aad(coupleId: String, questionId: String, userId: String): ByteArray =
"$coupleId|$questionId|$userId".toByteArray(Charsets.UTF_8)
private fun encodePayload(payload: AnswerPayload): ByteArray {
val ids = payload.selectedOptionIds.sorted().joinToString(",") { "\"${escape(it)}\"" }
val text = if (payload.writtenText != null) "\"${escape(payload.writtenText)}\"" else "null"
val scale = payload.scaleValue?.toString() ?: "null"
val json = "{\"scaleValue\":$scale,\"selectedOptionIds\":[$ids],\"writtenText\":$text}"
return json.toByteArray(Charsets.UTF_8)
}
private fun decodePayload(bytes: ByteArray): AnswerPayload {
val json = bytes.toString(Charsets.UTF_8)
// Manual parse — no Android JSON dependency so this runs in unit tests without Robolectric.
val scaleValue = extractField(json, "scaleValue")?.toIntOrNull()
val writtenText = extractString(json, "writtenText")
val selectedOptionIds = extractArray(json, "selectedOptionIds")
return AnswerPayload(writtenText, selectedOptionIds, scaleValue)
}
private fun extractField(json: String, key: String): String? {
val pattern = "\"$key\":([^,}]+)".toRegex()
return pattern.find(json)?.groupValues?.get(1)?.trim()?.takeIf { it != "null" }
}
private fun extractString(json: String, key: String): String? {
val pattern = "\"$key\":\"((?:[^\"\\\\]|\\\\.)*)\"".toRegex()
return pattern.find(json)?.groupValues?.get(1)?.unescape()
}
private fun extractArray(json: String, key: String): List<String> {
val pattern = "\"$key\":\\[([^]]*)]".toRegex()
val inner = pattern.find(json)?.groupValues?.get(1) ?: return emptyList()
if (inner.isBlank()) return emptyList()
return "\"((?:[^\"\\\\]|\\\\.)*)\"".toRegex()
.findAll(inner)
.map { it.groupValues[1].unescape() }
.toList()
}
private fun escape(s: String): String = buildString {
for (c in s) when (c) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(c)
}
}
private fun String.unescape(): String = replace("\\\"", "\"")
.replace("\\\\", "\\")
.replace("\\n", "\n")
.replace("\\r", "\r")
.replace("\\t", "\t")
companion object {
const val SEALED_PREFIX = "sealed:v1:"
}
}

View File

@ -0,0 +1,122 @@
package app.closer.crypto
import app.closer.data.remote.FirestoreDeviceKeyDataSource
import app.closer.data.remote.FirestoreReleaseKeyDataSource
import javax.inject.Inject
import javax.inject.Singleton
/**
* Orchestrates the two-sided reveal key exchange for sealed answers.
*
* Reveal flow (called once both answer docs exist):
* 1. [releaseOwnKey]: encrypt our pending one-time key to the partner's public key,
* write the keybox to Firestore. After this, the partner can decrypt our answer.
* 2. [decryptPartnerAnswer]: read the keybox the partner wrote for us, unwrap our copy
* of their one-time key, and decrypt their sealed answer payload.
*
* A user cannot see the partner's answer until their own key is released (step 1 must
* succeed before step 2 is attempted). This prevents one-sided reveal abuse.
*/
@Singleton
class SealedRevealManager @Inject constructor(
private val userKeyManager: UserKeyManager,
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
private val releaseKeyEncryptor: ReleaseKeyEncryptor,
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
private val deviceKeyDataSource: FirestoreDeviceKeyDataSource,
private val releaseKeyDataSource: FirestoreReleaseKeyDataSource
) {
/**
* Releases the user's own one-time answer key to their partner.
*
* @param coupleId The couple identifier.
* @param date The daily-question date string (YYYY-MM-DD).
* @param questionId The question ID (used as AAD).
* @param userId The current user's UID.
* @param partnerId The partner's UID.
* @return true if the key was released successfully, false if the pending key is missing.
*/
suspend fun releaseOwnKey(
coupleId: String,
date: String,
questionId: String,
userId: String,
partnerId: String
): Boolean {
val oneTimeKey = pendingAnswerKeyStore.load(questionId) ?: return false
val partnerPublicKey = deviceKeyDataSource.getPublicKey(partnerId) ?: return false
val keybox = releaseKeyEncryptor.wrapForRecipient(
oneTimeKey = oneTimeKey,
recipientPublicKeyB64 = partnerPublicKey,
coupleId = coupleId,
questionId = questionId,
senderUserId = userId,
recipientUserId = partnerId
)
releaseKeyDataSource.writeReleaseKey(
coupleId = coupleId,
date = date,
senderUserId = userId,
recipientUserId = partnerId,
encryptedAnswerKey = keybox
)
pendingAnswerKeyStore.remove(questionId)
return true
}
/**
* Decrypts the partner's sealed answer after they have released their key to us.
*
* @return The decrypted [SealedAnswerEncryptor.AnswerPayload], or null if the
* partner has not yet released their key.
*/
suspend fun decryptPartnerAnswer(
coupleId: String,
date: String,
questionId: String,
partnerId: String,
userId: String,
encryptedPayload: String
): SealedAnswerEncryptor.AnswerPayload? {
val keybox = releaseKeyDataSource.readReleaseKey(
coupleId = coupleId,
date = date,
senderUserId = partnerId,
recipientUserId = userId
) ?: return null
val myPrivateKey = userKeyManager.getOrCreatePrivateKey()
val oneTimeKey = releaseKeyEncryptor.unwrapFromSender(
keyboxB64 = keybox,
recipientPrivateKey = myPrivateKey,
coupleId = coupleId,
questionId = questionId,
senderUserId = partnerId,
recipientUserId = userId
)
return sealedAnswerEncryptor.open(
encryptedPayload = encryptedPayload,
keyHandle = oneTimeKey,
coupleId = coupleId,
questionId = questionId,
userId = partnerId
)
}
/**
* Ensures this user's public key is published to Firestore.
* Safe to call on every launch no-ops if already published.
*/
suspend fun ensurePublicKeyPublished(userId: String) {
val existing = deviceKeyDataSource.getPublicKey(userId)
if (existing != null) return
val privateKey = userKeyManager.getOrCreatePrivateKey()
val publicKeyB64 = userKeyManager.publicKeyB64(privateKey)
deviceKeyDataSource.publishPublicKey(userId, publicKeyB64)
}
}

View File

@ -0,0 +1,99 @@
package app.closer.crypto
import android.content.Context
import app.closer.data.local.SecurePreferencesFactory
import com.google.crypto.tink.CleartextKeysetHandle
import com.google.crypto.tink.HybridDecrypt
import com.google.crypto.tink.HybridEncrypt
import com.google.crypto.tink.JsonKeysetReader
import com.google.crypto.tink.JsonKeysetWriter
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.hybrid.HybridKeyTemplates
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayOutputStream
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
/**
* Manages the per-user ECIES keypair used for sealed-answer key release.
*
* Private key: persisted in EncryptedSharedPreferences (Keystore-backed).
* Public key: extracted on demand and published to Firestore as "pub:v1:{base64}" by
* [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).
*/
@Singleton
class UserKeyManager @Inject constructor(
@ApplicationContext context: Context
) {
private val prefs = SecurePreferencesFactory.encryptedSharedPreferences(context, PREFS_NAME)
/**
* Returns the existing private keypair, or generates and persists one if absent.
*/
fun getOrCreatePrivateKey(): KeysetHandle {
return loadPrivateKey() ?: run {
val handle = KeysetHandle.generateNew(HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM)
savePrivateKey(handle)
handle
}
}
fun loadPrivateKey(): KeysetHandle? = runCatching {
val json = prefs.getString(PRIVATE_KEY_PREF, null) ?: return null
CleartextKeysetHandle.read(JsonKeysetReader.withString(json))
}.getOrNull()
/**
* Serialises just the public portion of [privateKey] and returns it in the
* "pub:v1:{urlsafe-base64-no-padding}" wire format for Firestore storage.
*/
fun publicKeyB64(privateKey: KeysetHandle): String = publicKeyB64Companion(privateKey)
private fun savePrivateKey(handle: KeysetHandle) {
val baos = ByteArrayOutputStream()
CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(baos))
prefs.edit().putString(PRIVATE_KEY_PREF, baos.toString(Charsets.UTF_8.name())).apply()
}
companion object {
private const val PREFS_NAME = "user_key_secure"
private const val PRIVATE_KEY_PREF = "user_private_keyset"
const val PUB_PREFIX = "pub:v1:"
/**
* Stateless: returns a [HybridDecrypt] backed by [privateKey].
* Companion so [ReleaseKeyEncryptor] can call this without a Context instance.
*/
fun hybridDecryptFor(privateKey: KeysetHandle): HybridDecrypt =
privateKey.getPrimitive(HybridDecrypt::class.java)
/**
* Stateless: parses [publicKeyB64] (as stored in Firestore) and returns a [HybridEncrypt].
* Companion so [ReleaseKeyEncryptor] can call this without a Context instance.
*/
fun hybridEncryptFrom(publicKeyB64: String): HybridEncrypt {
require(publicKeyB64.startsWith(PUB_PREFIX)) { "Unexpected public key format" }
val bytes = Base64.getUrlDecoder().decode(publicKeyB64.removePrefix(PUB_PREFIX))
val handle = CleartextKeysetHandle.read(JsonKeysetReader.withBytes(bytes))
return handle.getPrimitive(HybridEncrypt::class.java)
}
/**
* Stateless: serialises just the public portion of [privateKey] and returns it
* in the "pub:v1:{urlsafe-base64-no-padding}" wire format.
* Available as a companion function so tests can use it without a Context.
*/
fun publicKeyB64Companion(privateKey: KeysetHandle): String {
val publicHandle = privateKey.publicKeysetHandle
val baos = java.io.ByteArrayOutputStream()
CleartextKeysetHandle.write(publicHandle, JsonKeysetWriter.withOutputStream(baos))
return PUB_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(baos.toByteArray())
}
}
}

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.LocalAnswer import app.closer.domain.model.LocalAnswer
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
@ -28,7 +32,11 @@ import kotlin.coroutines.resumeWithException
class FirestoreAnswerDataSource @Inject constructor( class FirestoreAnswerDataSource @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 dailyQuestionRef(coupleId: String, date: String) = private fun dailyQuestionRef(coupleId: String, date: String) =
@ -41,8 +49,9 @@ class FirestoreAnswerDataSource @Inject constructor(
dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId) dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
/** /**
* Persists the user's answer to Firestore. Creates or overwrites the answer * Persists the user's answer to Firestore. Uses sealed:v1: format (schemaVersion 3)
* document at `answers/{userId}`. * when the user has an ECIES keypair, giving partner-proof privacy. Falls back to
* enc:v1: format (schemaVersion 2) if no keypair is present yet.
*/ */
suspend fun saveAnswer( suspend fun saveAnswer(
coupleId: String, coupleId: String,
@ -50,6 +59,62 @@ class FirestoreAnswerDataSource @Inject constructor(
userId: String, userId: String,
answer: LocalAnswer, answer: LocalAnswer,
date: String = todayLocalDateString() date: String = todayLocalDateString()
): Unit {
val privateKey = userKeyManager.loadPrivateKey()
if (privateKey != null) {
saveAnswerSealed(coupleId, questionId, userId, answer, date)
} else {
saveAnswerEncrypted(coupleId, questionId, userId, answer, date)
}
}
private suspend fun saveAnswerSealed(
coupleId: String,
questionId: String,
userId: String,
answer: LocalAnswer,
date: String
): Unit = suspendCancellableCoroutine { cont ->
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
val payload = SealedAnswerEncryptor.AnswerPayload(
writtenText = answer.writtenText,
selectedOptionIds = answer.selectedOptionIds,
scaleValue = answer.scaleValue
)
val encryptedPayload = sealedAnswerEncryptor.seal(payload, oneTimeKey, coupleId, questionId, userId)
val commitment = answerCommitment.compute(
coupleId, questionId, userId,
answer.writtenText, answer.selectedOptionIds, answer.scaleValue
)
pendingAnswerKeyStore.store(questionId, oneTimeKey)
val data = mapOf(
"userId" to userId,
"questionId" to questionId,
"answerType" to answer.answerType,
"encryptedPayload" to encryptedPayload,
"commitmentHash" to commitment,
"schemaVersion" to 3,
"answerKeyReleased" to false,
"answerDate" to date,
"createdAt" to answer.createdAt,
"updatedAt" to answer.updatedAt,
"isRevealed" to answer.isRevealed
)
answerRef(coupleId, date, userId)
.set(data)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun saveAnswerEncrypted(
coupleId: String,
questionId: String,
userId: String,
answer: LocalAnswer,
date: String
): Unit = suspendCancellableCoroutine { cont -> ): Unit = suspendCancellableCoroutine { cont ->
val aead = encryptionManager.requireAead(coupleId) val aead = encryptionManager.requireAead(coupleId)
val data = mapOf( val data = mapOf(
@ -63,6 +128,8 @@ class FirestoreAnswerDataSource @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,
"answerDate" to date,
"createdAt" to answer.createdAt, "createdAt" to answer.createdAt,
"updatedAt" to answer.updatedAt, "updatedAt" to answer.updatedAt,
"isRevealed" to answer.isRevealed "isRevealed" to answer.isRevealed
@ -138,8 +205,28 @@ class FirestoreAnswerDataSource @Inject constructor(
aead: com.google.crypto.tink.Aead?, aead: com.google.crypto.tink.Aead?,
coupleId: String coupleId: String
): LocalAnswer { ): LocalAnswer {
val schemaVersion = getLong("schemaVersion")?.toInt() ?: 2
// schemaVersion 3: sealed:v1: — content is in encryptedPayload, not top-level fields.
// The calling code (reveal flow) is responsible for decrypting via SealedRevealManager.
if (schemaVersion == 3) {
return LocalAnswer(
questionId = getString("questionId") ?: "",
questionText = "",
category = "",
answerType = getString("answerType") ?: "written",
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
isRevealed = getBoolean("isRevealed") ?: false,
schemaVersion = 3,
isSealed = getBoolean("answerKeyReleased") != true,
encryptedPayload = getString("encryptedPayload"),
answerDate = getString("answerDate") ?: ""
)
}
// schemaVersion 2: enc:v1: — decrypt with couple AEAD.
val rawIds = get("selectedOptionIds") as? List<String> ?: emptyList() val rawIds = get("selectedOptionIds") as? List<String> ?: emptyList()
// selectedOptionIds is stored as a single encrypted JSON blob OR a plaintext list
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)
if (decrypted != null) runCatching { if (decrypted != null) runCatching {
@ -169,7 +256,9 @@ class FirestoreAnswerDataSource @Inject constructor(
scaleValue = scaleValue, scaleValue = scaleValue,
createdAt = getLong("createdAt") ?: System.currentTimeMillis(), createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(), updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
isRevealed = getBoolean("isRevealed") ?: false isRevealed = getBoolean("isRevealed") ?: false,
schemaVersion = schemaVersion,
answerDate = getString("answerDate") ?: ""
) )
} }

View File

@ -18,6 +18,8 @@ object FirestoreCollections {
const val ENTITLEMENTS = "entitlements" const val ENTITLEMENTS = "entitlements"
const val FCM_TOKENS = "fcmTokens" const val FCM_TOKENS = "fcmTokens"
const val ENTITLEMENT_PREMIUM_DOC = "premium" const val ENTITLEMENT_PREMIUM_DOC = "premium"
const val DEVICES = "devices"
const val DEVICE_PRIMARY = "primary"
} }
// ── Subcollections under couples/{coupleId} ─────────────────────────────── // ── Subcollections under couples/{coupleId} ───────────────────────────────
@ -43,6 +45,11 @@ object FirestoreCollections {
const val ANSWERS = "answers" const val ANSWERS = "answers"
} }
// ── Subcollections under …/daily_question/{date}/answers/{userId} ─────────
object Answers {
const val RELEASE_KEYS = "releaseKeys"
}
// ── Subcollections under …/question_threads/{threadId} ──────────────────── // ── Subcollections under …/question_threads/{threadId} ────────────────────
object QuestionThreads { object QuestionThreads {
const val ANSWERS = "answers" const val ANSWERS = "answers"

View File

@ -0,0 +1,44 @@
package app.closer.data.remote
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
/**
* Stores and retrieves the per-user ECIES public key at
* users/{userId}/devices/{deviceId}.
*
* Only the public key is stored. Private key material never leaves the device.
* The document is merged (not replaced) so that future multi-device fields can
* be added without stomping existing data.
*/
@Singleton
class FirestoreDeviceKeyDataSource @Inject constructor(
private val db: FirebaseFirestore
) {
suspend fun publishPublicKey(userId: String, publicKeyB64: String) {
deviceRef(userId)
.set(
mapOf(
"deviceId" to FirestoreCollections.Users.DEVICE_PRIMARY,
"publicKey" to publicKeyB64,
"platform" to "android",
"updatedAt" to System.currentTimeMillis()
),
SetOptions.merge()
)
.await()
}
suspend fun getPublicKey(userId: String): String? =
deviceRef(userId).get().await().getString("publicKey")
private fun deviceRef(userId: String) =
db.collection(FirestoreCollections.USERS)
.document(userId)
.collection(FirestoreCollections.Users.DEVICES)
.document(FirestoreCollections.Users.DEVICE_PRIMARY)
}

View File

@ -0,0 +1,73 @@
package app.closer.data.remote
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.tasks.await
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads and writes release-key documents at
* couples/{coupleId}/daily_question/{date}/answers/{senderUserId}/releaseKeys/{recipientUserId}.
*
* A release key is the sender's one-time answer key, ECIES-encrypted to the
* recipient's public key. Writing is create-only: if a release key already exists
* for this sender/recipient pair the write is skipped to prevent overwrite attacks.
*
* Firestore rules independently enforce immutability server-side.
*/
@Singleton
class FirestoreReleaseKeyDataSource @Inject constructor(
private val db: FirebaseFirestore
) {
/**
* Writes the release key for [recipientUserId] under [senderUserId]'s answer doc.
* No-ops if a release key already exists (idempotent retry safety).
*/
suspend fun writeReleaseKey(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String,
encryptedAnswerKey: String
) {
val ref = releaseKeyRef(coupleId, date, senderUserId, recipientUserId)
if (ref.get().await().exists()) return
ref.set(
mapOf(
"recipientUserId" to recipientUserId,
"encryptedAnswerKey" to encryptedAnswerKey,
"releasedAt" to System.currentTimeMillis()
)
).await()
}
/**
* Reads the release key that [senderUserId] wrote for [recipientUserId].
* Returns null if the release key does not exist yet.
*/
suspend fun readReleaseKey(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String
): String? =
releaseKeyRef(coupleId, date, senderUserId, recipientUserId)
.get()
.await()
.getString("encryptedAnswerKey")
private fun releaseKeyRef(
coupleId: String,
date: String,
senderUserId: String,
recipientUserId: String
) = db.collection(FirestoreCollections.COUPLES)
.document(coupleId)
.collection(FirestoreCollections.Couples.DAILY_QUESTION)
.document(date)
.collection(FirestoreCollections.DailyQuestion.ANSWERS)
.document(senderUserId)
.collection(FirestoreCollections.Answers.RELEASE_KEYS)
.document(recipientUserId)
}

View File

@ -11,5 +11,13 @@ data class LocalAnswer(
val scaleValue: Int? = null, val scaleValue: Int? = null,
val createdAt: Long = System.currentTimeMillis(), val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis(), val updatedAt: Long = System.currentTimeMillis(),
val isRevealed: Boolean = false val isRevealed: Boolean = false,
// schemaVersion 2 = enc:v1: (couple-key); 3 = sealed:v1: (partner-proof)
val schemaVersion: Int = 2,
// true while a sealed answer has been submitted but the reveal key has not been released
val isSealed: Boolean = false,
// raw sealed payload retained so the reveal flow can decrypt it later
val encryptedPayload: String? = null,
// YYYY-MM-DD string matching the daily_question/{date} document key — required for release key path
val answerDate: String = ""
) )

View File

@ -70,6 +70,7 @@ fun AnswerRevealScreen(
viewModel.revealAnswer() viewModel.revealAnswer()
viewModel.refreshPartnerAnswer() viewModel.refreshPartnerAnswer()
}, },
onRefresh = viewModel::refreshPartnerAnswer,
onAnswerQuestion = { onAnswerQuestion = {
val coupleId = state.coupleId val coupleId = state.coupleId
if (coupleId != null) { if (coupleId != null) {
@ -94,6 +95,7 @@ private fun AnswerRevealContent(
onAnswerQuestion: () -> Unit, onAnswerQuestion: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
onHome: () -> Unit, onHome: () -> Unit,
onRefresh: () -> Unit = {},
onFollowUpSelected: (FollowUpOption) -> Unit = {}, onFollowUpSelected: (FollowUpOption) -> Unit = {},
onSnackbarShown: () -> Unit = {} onSnackbarShown: () -> Unit = {}
) { ) {
@ -151,6 +153,22 @@ private fun AnswerRevealContent(
onAnswerQuestion = onAnswerQuestion, onAnswerQuestion = onAnswerQuestion,
onHome = onHome onHome = onHome
) )
// Sealed reveal phases (schemaVersion 3) — checked before isRevealed
state.sealedRevealPhase == SealedRevealPhase.RELEASING_KEY ->
ReleasingKeyState()
state.sealedRevealPhase == SealedRevealPhase.WAITING_FOR_PARTNER ->
WaitingForPartnerState(onRefresh = onRefresh, onHome = onHome)
state.sealedRevealPhase == SealedRevealPhase.LOST_LOCAL_KEY ->
LostLocalKeyState(onHome = onHome)
state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED ->
BothAnsweredSealedState(
question = state.question,
onReveal = onReveal,
onHistory = onHistory
)
state.sealedRevealPhase == SealedRevealPhase.ANSWER_SEALED ->
AnswerSealedState(question = state.question, onHome = onHome)
// Legacy (enc:v1:) reveal path
!state.answer.isRevealed -> ReadyToRevealState( !state.answer.isRevealed -> ReadyToRevealState(
answer = state.answer, answer = state.answer,
partnerAnswer = state.partnerAnswer, partnerAnswer = state.partnerAnswer,
@ -300,6 +318,185 @@ private fun ReadyToRevealState(
} }
} }
// ── Sealed-answer reveal states (Batch 11) ───────────────────────────────────
@Composable
private fun AnswerSealedState(
question: Question?,
onHome: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
RevealPill("Your answer is sealed")
Text(
text = question?.text ?: "Your answer is private.",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Waiting for your partner. Reveal opens once both of you have answered.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
OutlinedButton(
onClick = onHome,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Back to home")
}
}
}
}
@Composable
private fun BothAnsweredSealedState(
question: Question?,
onReveal: () -> Unit,
onHistory: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
RevealPill("Both answers are in")
Text(
text = question?.text ?: "Both of you answered.",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Reveal is ready. Open it when you're both here — answers are exchanged privately between your devices.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onReveal,
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF24122F)
)
) {
Text("Reveal answer")
}
OutlinedButton(
onClick = onHistory,
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Saved answers")
}
}
}
}
}
@Composable
private fun ReleasingKeyState() {
RevealMessageCard {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp)
) {
CircularProgressIndicator(color = Color(0xFFB98AF4))
Text(
text = "Opening reveal…",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun WaitingForPartnerState(
onRefresh: () -> Unit,
onHome: () -> Unit
) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
RevealPill("Almost there")
Text(
text = "Waiting for your partner to finish reveal.",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Your answer key is released. Once your partner opens their reveal, both answers will appear here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 4,
overflow = TextOverflow.Ellipsis
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = onRefresh,
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFFB98AF4),
contentColor = Color(0xFF24122F)
)
) {
Text("Check again")
}
OutlinedButton(
onClick = onHome,
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Back to home")
}
}
}
}
}
@Composable
private fun LostLocalKeyState(onHome: () -> Unit) {
RevealMessageCard {
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
RevealPill("Reveal unavailable")
Text(
text = "This answer cannot be revealed from this device.",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
maxLines = 3,
overflow = TextOverflow.Ellipsis
)
Text(
text = "The sealed answer key was stored on the device you originally answered on. Open the app on that device to complete the reveal.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 5,
overflow = TextOverflow.Ellipsis
)
OutlinedButton(
onClick = onHome,
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
shape = RoundedCornerShape(16.dp)
) {
Text("Back to home")
}
}
}
}
@Composable @Composable
private fun RevealedState( private fun RevealedState(
answer: LocalAnswer, answer: LocalAnswer,

View File

@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.core.crash.CrashReporter import app.closer.core.crash.CrashReporter
import app.closer.crypto.PendingAnswerKeyStore
import app.closer.crypto.SealedRevealManager
import app.closer.data.remote.FirestoreAnswerDataSource import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.domain.model.LocalAnswer import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question import app.closer.domain.model.Question
@ -27,6 +29,28 @@ enum class FollowUpOption(val label: String, val route: String? = null) {
ANOTHER_QUESTION("Want to try another question from this category?") ANOTHER_QUESTION("Want to try another question from this category?")
} }
/**
* Tracks where we are in the sealed-answer reveal exchange.
*
* State machine:
* NONE answer is enc:v1: (schemaVersion 2); use existing non-sealed reveal path
* ANSWER_SEALED own answer submitted; partner has not answered yet
* BOTH_ANSWERED both answers submitted; "Reveal is ready" but keys not released yet
* RELEASING_KEY writing our one-time key to Firestore (in-flight)
* WAITING_FOR_PARTNER our key released; partner has not released theirs yet
* REVEALED both keys released, partner answer decrypted and visible
* LOST_LOCAL_KEY the pending answer key is missing from this device (unrecoverable)
*/
enum class SealedRevealPhase {
NONE,
ANSWER_SEALED,
BOTH_ANSWERED,
RELEASING_KEY,
WAITING_FOR_PARTNER,
REVEALED,
LOST_LOCAL_KEY
}
data class AnswerRevealUiState( data class AnswerRevealUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val error: String? = null, val error: String? = null,
@ -36,7 +60,8 @@ data class AnswerRevealUiState(
val coupleId: String? = null, val coupleId: String? = null,
val partnerId: String? = null, val partnerId: String? = null,
val followUpOptions: List<FollowUpOption> = emptyList(), val followUpOptions: List<FollowUpOption> = emptyList(),
val snackbarMessage: String? = null val snackbarMessage: String? = null,
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE
) )
@HiltViewModel @HiltViewModel
@ -47,6 +72,8 @@ class AnswerRevealViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter, private val crashReporter: CrashReporter,
private val sealedRevealManager: SealedRevealManager,
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle
) : ViewModel() { ) : ViewModel() {
@ -72,12 +99,14 @@ class AnswerRevealViewModel @Inject constructor(
firestoreAnswerDataSource.getAnswerForUser( firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId, coupleId = coupleId,
userId = partnerId, userId = partnerId,
date = FirestoreAnswerDataSource.todayLocalDateString() date = effectiveDate(answer)
) )
}.onFailure { crashReporter.recordException(it) }.getOrNull() }.onFailure { crashReporter.recordException(it) }.getOrNull()
} else null } else null
val sealedPhase = computeSealedPhase(answer, partnerAnswer)
val category = answer?.category ?: question?.category ?: "" val category = answer?.category ?: question?.category ?: ""
_uiState.value = AnswerRevealUiState( _uiState.value = AnswerRevealUiState(
isLoading = false, isLoading = false,
question = question, question = question,
@ -85,6 +114,7 @@ class AnswerRevealViewModel @Inject constructor(
partnerAnswer = partnerAnswer, partnerAnswer = partnerAnswer,
coupleId = coupleId, coupleId = coupleId,
partnerId = partnerId, partnerId = partnerId,
sealedRevealPhase = sealedPhase,
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category) followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
) )
} catch (e: Exception) { } catch (e: Exception) {
@ -97,9 +127,13 @@ class AnswerRevealViewModel @Inject constructor(
} }
} }
/** private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
* Resolves the current user's couple and partner IDs. if (answer?.schemaVersion != 3) return SealedRevealPhase.NONE
*/ if (answer.isRevealed) return SealedRevealPhase.REVEALED
if (!pendingAnswerKeyStore.hasPendingKey(questionId)) return SealedRevealPhase.LOST_LOCAL_KEY
return if (partnerAnswer != null) SealedRevealPhase.BOTH_ANSWERED else SealedRevealPhase.ANSWER_SEALED
}
private suspend fun resolveCoupleAndPartner(): Pair<String?, String?> { private suspend fun resolveCoupleAndPartner(): Pair<String?, String?> {
val userId = authRepository.currentUserId ?: return null to null val userId = authRepository.currentUserId ?: return null to null
val couple = runCatching { coupleRepository.getCoupleForUser(userId) } val couple = runCatching { coupleRepository.getCoupleForUser(userId) }
@ -123,35 +157,123 @@ class AnswerRevealViewModel @Inject constructor(
val coupleId = state.coupleId ?: return val coupleId = state.coupleId ?: return
val partnerId = state.partnerId ?: return val partnerId = state.partnerId ?: return
viewModelScope.launch { viewModelScope.launch {
if (state.sealedRevealPhase == SealedRevealPhase.RELEASING_KEY) return@launch
if (state.sealedRevealPhase == SealedRevealPhase.WAITING_FOR_PARTNER) {
tryDecryptPartnerAnswer(coupleId, partnerId, state)
return@launch
}
val partnerAnswer = runCatching { val partnerAnswer = runCatching {
firestoreAnswerDataSource.getAnswerForUser( firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId, coupleId = coupleId,
userId = partnerId, userId = partnerId,
date = FirestoreAnswerDataSource.todayLocalDateString() date = effectiveDate(state.answer)
) )
}.onFailure { crashReporter.recordException(it) }.getOrNull() }.onFailure { crashReporter.recordException(it) }.getOrNull()
partnerAnswer?.let { _uiState.update { it.copy(partnerAnswer = partnerAnswer) } } partnerAnswer?.let { pa ->
val newPhase = computeSealedPhase(state.answer, pa)
_uiState.update { it.copy(partnerAnswer = pa, sealedRevealPhase = newPhase) }
}
} }
} }
fun revealAnswer() { fun revealAnswer() {
val state = _uiState.value
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
performSealedReveal(state)
} else {
performLegacyReveal()
}
}
private fun performSealedReveal(state: AnswerRevealUiState) {
val coupleId = state.coupleId ?: return
val partnerId = state.partnerId ?: return
val userId = authRepository.currentUserId ?: return
val date = effectiveDate(state.answer)
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.RELEASING_KEY) }
viewModelScope.launch {
val released = runCatching {
sealedRevealManager.releaseOwnKey(
coupleId = coupleId,
date = date,
questionId = questionId,
userId = userId,
partnerId = partnerId
)
}.onFailure { crashReporter.recordException(it) }.getOrDefault(false)
if (!released) {
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.LOST_LOCAL_KEY) }
return@launch
}
tryDecryptPartnerAnswer(coupleId, partnerId, _uiState.value)
}
}
private suspend fun tryDecryptPartnerAnswer(
coupleId: String,
partnerId: String,
state: AnswerRevealUiState
) {
val userId = authRepository.currentUserId ?: return
val encryptedPayload = state.partnerAnswer?.encryptedPayload
if (encryptedPayload == null) {
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.WAITING_FOR_PARTNER) }
return
}
val payload = runCatching {
sealedRevealManager.decryptPartnerAnswer(
coupleId = coupleId,
date = effectiveDate(state.partnerAnswer),
questionId = questionId,
partnerId = partnerId,
userId = userId,
encryptedPayload = encryptedPayload
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
if (payload == null) {
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.WAITING_FOR_PARTNER) }
return
}
val decryptedPartnerAnswer = state.partnerAnswer?.copy(
writtenText = payload.writtenText,
selectedOptionIds = payload.selectedOptionIds,
scaleValue = payload.scaleValue,
isSealed = false
)
localAnswerRepository.markRevealed(questionId)
val ownAnswer = localAnswerRepository.getAnswer(questionId)
val category = ownAnswer?.category ?: state.question?.category ?: ""
_uiState.update {
it.copy(
answer = ownAnswer,
partnerAnswer = decryptedPartnerAnswer,
sealedRevealPhase = SealedRevealPhase.REVEALED,
followUpOptions = generateFollowUpOptions(ownAnswer, decryptedPartnerAnswer, category)
)
}
}
private fun performLegacyReveal() {
viewModelScope.launch { viewModelScope.launch {
localAnswerRepository.markRevealed(questionId) localAnswerRepository.markRevealed(questionId)
val answer = localAnswerRepository.getAnswer(questionId) val answer = localAnswerRepository.getAnswer(questionId)
val partnerAnswer = _uiState.value.partnerAnswer val partnerAnswer = _uiState.value.partnerAnswer
val category = answer?.category ?: _uiState.value.question?.category ?: "" val category = answer?.category ?: _uiState.value.question?.category ?: ""
_uiState.update { _uiState.update {
it.copy( it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
)
} }
} }
} }
/**
* Selects up to 2 optional follow-up prompts for after a reveal.
* Uses category context when available and never creates pressure.
*/
private fun generateFollowUpOptions( private fun generateFollowUpOptions(
answer: LocalAnswer?, answer: LocalAnswer?,
partnerAnswer: LocalAnswer?, partnerAnswer: LocalAnswer?,
@ -190,4 +312,8 @@ class AnswerRevealViewModel @Inject constructor(
fun clearSnackbar() { fun clearSnackbar() {
_uiState.update { it.copy(snackbarMessage = null) } _uiState.update { it.copy(snackbarMessage = null) }
} }
private fun effectiveDate(answer: LocalAnswer?): String =
answer?.answerDate?.takeIf { it.isNotBlank() }
?: FirestoreAnswerDataSource.todayLocalDateString()
} }

View File

@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus import app.closer.crypto.EncryptionStatus
import app.closer.crypto.SealedRevealManager
import app.closer.data.remote.FirestoreAnswerDataSource import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.data.remote.FirestoreCapsuleDataSource import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.data.remote.FirestoreChallengeDataSource import app.closer.data.remote.FirestoreChallengeDataSource
@ -141,7 +142,8 @@ class HomeViewModel @Inject constructor(
private val questionSessionRepository: QuestionSessionRepository, private val questionSessionRepository: QuestionSessionRepository,
private val challengeDataSource: FirestoreChallengeDataSource, private val challengeDataSource: FirestoreChallengeDataSource,
private val capsuleDataSource: FirestoreCapsuleDataSource, private val capsuleDataSource: FirestoreCapsuleDataSource,
private val datePlanRepository: DatePlanRepository private val datePlanRepository: DatePlanRepository,
private val sealedRevealManager: SealedRevealManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState()) private val _uiState = MutableStateFlow(HomeUiState())
@ -178,6 +180,7 @@ class HomeViewModel @Inject constructor(
) )
} }
val uid = authRepository.currentUserId val uid = authRepository.currentUserId
uid?.let { launch { runCatching { sealedRevealManager.ensurePublicKeyPublished(it) } } }
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() } val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId -> val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId ->
runCatching { userRepository.getUser(partnerId)?.displayName } runCatching { userRepository.getUser(partnerId)?.displayName }

View File

@ -0,0 +1,69 @@
package app.closer.crypto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AnswerCommitmentTest {
private val subject = AnswerCommitment()
@Test
fun `same inputs produce same hash`() {
val a = subject.compute("cpl1", "q1", "u1", "yes", listOf("a", "b"), 3)
val b = subject.compute("cpl1", "q1", "u1", "yes", listOf("a", "b"), 3)
assertEquals(a, b)
}
@Test
fun `hash starts with sha256 prefix`() {
val hash = subject.compute("cpl1", "q1", "u1", null, emptyList(), null)
assertTrue(hash.startsWith(AnswerCommitment.PREFIX))
}
@Test
fun `different writtenText produces different hash`() {
val a = subject.compute("cpl1", "q1", "u1", "yes", emptyList(), null)
val b = subject.compute("cpl1", "q1", "u1", "no", emptyList(), null)
assertNotEquals(a, b)
}
@Test
fun `different coupleId produces different hash`() {
val a = subject.compute("cpl1", "q1", "u1", "hello", emptyList(), null)
val b = subject.compute("cpl2", "q1", "u1", "hello", emptyList(), null)
assertNotEquals(a, b)
}
@Test
fun `selectedOptionIds order does not affect hash`() {
val a = subject.compute("cpl1", "q1", "u1", null, listOf("x", "y"), null)
val b = subject.compute("cpl1", "q1", "u1", null, listOf("y", "x"), null)
assertEquals(a, b)
}
@Test
fun `canonical JSON has fixed key order`() {
val canon = subject.canonical("hello", listOf("b", "a"), 2)
assertEquals(
"{\"scaleValue\":2,\"selectedOptionIds\":[\"a\",\"b\"],\"writtenText\":\"hello\"}",
canon
)
}
@Test
fun `canonical JSON handles null values`() {
val canon = subject.canonical(null, emptyList(), null)
assertEquals(
"{\"scaleValue\":null,\"selectedOptionIds\":[],\"writtenText\":null}",
canon
)
}
@Test
fun `canonical JSON escapes special characters`() {
val canon = subject.canonical("say \"hi\"", emptyList(), null)
assertTrue(canon.contains("say \\\"hi\\\""))
}
}

View File

@ -0,0 +1,91 @@
package app.closer.crypto
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.hybrid.HybridConfig
import com.google.crypto.tink.hybrid.HybridKeyTemplates
import com.google.crypto.tink.KeysetHandle
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Test
import java.security.GeneralSecurityException
class ReleaseKeyEncryptorTest {
companion object {
@BeforeClass
@JvmStatic
fun setup() {
AeadConfig.register()
HybridConfig.register()
}
private fun eciesKeypair(): KeysetHandle =
KeysetHandle.generateNew(HybridKeyTemplates.ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM)
}
private val sealedEncryptor = SealedAnswerEncryptor()
private val subject = ReleaseKeyEncryptor(sealedEncryptor)
private val coupleId = "couple-abc"
private val questionId = "q-001"
private val aliceId = "user-alice"
private val bobId = "user-bob"
@Test
fun `wrapForRecipient produces keybox prefix`() {
val bobKeypair = eciesKeypair()
val bobPublicKeyB64 = UserKeyManager.publicKeyB64Companion(bobKeypair)
val oneTimeKey = sealedEncryptor.generateOneTimeKey()
val keybox = subject.wrapForRecipient(oneTimeKey, bobPublicKeyB64, coupleId, questionId, aliceId, bobId)
assertTrue(keybox.startsWith(ReleaseKeyEncryptor.KEYBOX_PREFIX))
}
@Test
fun `wrap and unwrap round-trip restores the answer key`() {
val aliceKeypair = eciesKeypair()
val bobKeypair = eciesKeypair()
val aliceOneTimeKey = sealedEncryptor.generateOneTimeKey()
val bobPublicKeyB64 = UserKeyManager.publicKeyB64Companion(bobKeypair)
val keybox = subject.wrapForRecipient(
aliceOneTimeKey, bobPublicKeyB64, coupleId, questionId, aliceId, bobId
)
val recoveredKey = subject.unwrapFromSender(keybox, bobKeypair, coupleId, questionId, aliceId, bobId)
val payload = SealedAnswerEncryptor.AnswerPayload("weekend away", listOf("opt-1"), 9)
val sealed = sealedEncryptor.seal(payload, aliceOneTimeKey, coupleId, questionId, aliceId)
val decrypted = sealedEncryptor.open(sealed, recoveredKey, coupleId, questionId, aliceId)
assertEquals(payload.writtenText, decrypted.writtenText)
assertEquals(payload.scaleValue, decrypted.scaleValue)
}
@Test(expected = GeneralSecurityException::class)
fun `wrong recipient private key cannot unwrap`() {
val bobKeypair = eciesKeypair()
val eveKeypair = eciesKeypair()
val oneTimeKey = sealedEncryptor.generateOneTimeKey()
val bobPublicKeyB64 = UserKeyManager.publicKeyB64Companion(bobKeypair)
val keybox = subject.wrapForRecipient(oneTimeKey, bobPublicKeyB64, coupleId, questionId, aliceId, bobId)
// Eve tries to unwrap Bob's keybox using her own private key.
subject.unwrapFromSender(keybox, eveKeypair, coupleId, questionId, aliceId, bobId)
}
@Test(expected = GeneralSecurityException::class)
fun `wrong context info cannot unwrap`() {
val bobKeypair = eciesKeypair()
val oneTimeKey = sealedEncryptor.generateOneTimeKey()
val bobPublicKeyB64 = UserKeyManager.publicKeyB64Companion(bobKeypair)
val keybox = subject.wrapForRecipient(oneTimeKey, bobPublicKeyB64, coupleId, questionId, aliceId, bobId)
// Wrong questionId in context info.
subject.unwrapFromSender(keybox, bobKeypair, coupleId, "other-question", aliceId, bobId)
}
}

View File

@ -0,0 +1,130 @@
package app.closer.crypto
import com.google.crypto.tink.aead.AeadConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.BeforeClass
import org.junit.Test
import java.security.GeneralSecurityException
class SealedAnswerEncryptorTest {
companion object {
@BeforeClass
@JvmStatic
fun setup() {
AeadConfig.register()
}
}
private val subject = SealedAnswerEncryptor()
private val coupleId = "couple-abc"
private val questionId = "q-001"
private val userId = "user-alice"
private val payload = SealedAnswerEncryptor.AnswerPayload(
writtenText = "I love rainy evenings at home.",
selectedOptionIds = listOf("opt-b", "opt-a"),
scaleValue = 7
)
@Test
fun `sealed payload starts with sealed prefix`() {
val key = subject.generateOneTimeKey()
val sealed = subject.seal(payload, key, coupleId, questionId, userId)
assertTrue(sealed.startsWith(SealedAnswerEncryptor.SEALED_PREFIX))
}
@Test
fun `sealed payload does not contain plaintext`() {
val key = subject.generateOneTimeKey()
val sealed = subject.seal(payload, key, coupleId, questionId, userId)
assertFalse(sealed.contains(payload.writtenText!!))
payload.selectedOptionIds.forEach { assertFalse(sealed.contains(it)) }
}
@Test
fun `round-trip restores all payload fields`() {
val key = subject.generateOneTimeKey()
val sealed = subject.seal(payload, key, coupleId, questionId, userId)
val opened = subject.open(sealed, key, coupleId, questionId, userId)
assertEquals(payload.writtenText, opened.writtenText)
assertEquals(payload.selectedOptionIds.sorted(), opened.selectedOptionIds.sorted())
assertEquals(payload.scaleValue, opened.scaleValue)
}
@Test
fun `round-trip works for null fields`() {
val key = subject.generateOneTimeKey()
val nullPayload = SealedAnswerEncryptor.AnswerPayload(null, emptyList(), null)
val sealed = subject.seal(nullPayload, key, coupleId, questionId, userId)
val opened = subject.open(sealed, key, coupleId, questionId, userId)
assertNull(opened.writtenText)
assertEquals(emptyList<String>(), opened.selectedOptionIds)
assertNull(opened.scaleValue)
}
@Test(expected = GeneralSecurityException::class)
fun `wrong key cannot decrypt`() {
val aliceKey = subject.generateOneTimeKey()
val bobKey = subject.generateOneTimeKey()
val sealed = subject.seal(payload, aliceKey, coupleId, questionId, userId)
subject.open(sealed, bobKey, coupleId, questionId, userId)
}
@Test(expected = GeneralSecurityException::class)
fun `wrong coupleId cannot decrypt`() {
val key = subject.generateOneTimeKey()
val sealed = subject.seal(payload, key, coupleId, questionId, userId)
subject.open(sealed, key, "other-couple", questionId, userId)
}
@Test(expected = GeneralSecurityException::class)
fun `wrong questionId cannot decrypt`() {
val key = subject.generateOneTimeKey()
val sealed = subject.seal(payload, key, coupleId, questionId, userId)
subject.open(sealed, key, coupleId, "other-question", userId)
}
@Test(expected = GeneralSecurityException::class)
fun `wrong userId cannot decrypt`() {
val key = subject.generateOneTimeKey()
val sealed = subject.seal(payload, key, coupleId, questionId, userId)
subject.open(sealed, key, coupleId, questionId, "other-user")
}
@Test
fun `key serialization round-trip`() {
val key = subject.generateOneTimeKey()
val json = subject.serializeKey(key)
val restored = subject.deserializeKey(json)
val sealed = subject.seal(payload, key, coupleId, questionId, userId)
val opened = subject.open(sealed, restored, coupleId, questionId, userId)
assertEquals(payload.writtenText, opened.writtenText)
}
@Test
fun `each seal call produces different ciphertext`() {
val key = subject.generateOneTimeKey()
val s1 = subject.seal(payload, key, coupleId, questionId, userId)
val s2 = subject.seal(payload, key, coupleId, questionId, userId)
// AES-256-GCM with random nonce: same plaintext → different ciphertext.
assertFalse(s1 == s2)
}
@Test
fun `special characters in answer survive round-trip`() {
val key = subject.generateOneTimeKey()
val special = SealedAnswerEncryptor.AnswerPayload(
writtenText = "she said \"hello\"\nand left.\ttab here",
selectedOptionIds = emptyList(),
scaleValue = null
)
val sealed = subject.seal(special, key, coupleId, questionId, userId)
val opened = subject.open(sealed, key, coupleId, questionId, userId)
assertEquals(special.writtenText, opened.writtenText)
}
}

View File

@ -0,0 +1,80 @@
{
"_comment": "Cross-platform test vectors for the sealed-answer crypto protocol.",
"_note": "Values are stable inputs and commitments. The ciphertext fields are illustrative — run the regeneration script to produce real ciphertexts for CI.",
"_regenerate": "cd app && ./gradlew :app:testDebugUnitTest --tests 'app.closer.crypto.SealedAnswerTestVectorGenerator'",
"protocol_version": "v1",
"commitment": {
"_spec": "SHA-256(\"v1|{coupleId}|{questionId}|{userId}|{canonicalJson}\"), urlsafe-base64-no-padding",
"_canonical_json_key_order": ["scaleValue", "selectedOptionIds", "writtenText"],
"_selectedOptionIds_order": "lexicographic ascending",
"vectors": [
{
"id": "commit-001",
"input": {
"coupleId": "couple-abc",
"questionId": "q-001",
"userId": "user-alice",
"writtenText": "I love rainy evenings at home.",
"selectedOptionIds": ["opt-b", "opt-a"],
"scaleValue": 7
},
"canonical_json": "{\"scaleValue\":7,\"selectedOptionIds\":[\"opt-a\",\"opt-b\"],\"writtenText\":\"I love rainy evenings at home.\"}",
"commitment_hash": "sha256:REGENERATE_WITH_SCRIPT"
},
{
"id": "commit-002-nulls",
"input": {
"coupleId": "couple-abc",
"questionId": "q-002",
"userId": "user-bob",
"writtenText": null,
"selectedOptionIds": [],
"scaleValue": null
},
"canonical_json": "{\"scaleValue\":null,\"selectedOptionIds\":[],\"writtenText\":null}",
"commitment_hash": "sha256:REGENERATE_WITH_SCRIPT"
},
{
"id": "commit-003-special-chars",
"input": {
"coupleId": "couple-xyz",
"questionId": "q-003",
"userId": "user-alice",
"writtenText": "she said \"hello\"\nand left.\ttab here",
"selectedOptionIds": [],
"scaleValue": null
},
"canonical_json": "{\"scaleValue\":null,\"selectedOptionIds\":[],\"writtenText\":\"she said \\\"hello\\\"\\nand left.\\ttab here\"}",
"commitment_hash": "sha256:REGENERATE_WITH_SCRIPT"
}
]
},
"sealed_payload": {
"_spec": "AES-256-GCM via Tink AEAD. AAD = UTF-8('{coupleId}|{questionId}|{userId}'). Wire format: 'sealed:v1:{urlsafe-base64-no-padding}'.",
"_note": "Ciphertext is non-deterministic (random nonce per encrypt call). Test by decrypt-round-trip, not by fixed ciphertext.",
"aad_format": "{coupleId}|{questionId}|{userId}",
"payload_format": "{\"scaleValue\":{int_or_null},\"selectedOptionIds\":[...sorted...],\"writtenText\":{string_or_null}}"
},
"release_key": {
"_spec": "ECIES-P256-HKDF-HMAC-SHA256-AES128-GCM via Tink HybridEncrypt. Context info = UTF-8('{coupleId}|{questionId}|{senderUserId}|{recipientUserId}'). Wire format: 'keybox:v1:{urlsafe-base64-no-padding}'.",
"context_info_format": "{coupleId}|{questionId}|{senderUserId}|{recipientUserId}"
},
"public_key": {
"_spec": "Tink ECIES public keyset serialised as JSON, then urlsafe-base64-no-padding. Wire format: 'pub:v1:{base64}'.",
"algorithm": "ECIES_P256_HKDF_HMAC_SHA256_AES128_GCM"
},
"ios_compat_notes": [
"Use urlsafe Base64 (no padding) for all wire formats.",
"Canonical JSON key order is fixed: scaleValue, selectedOptionIds, writtenText.",
"selectedOptionIds must be sorted lexicographically before hashing and before encryption.",
"String encoding is always UTF-8.",
"SHA-256 input is UTF-8 bytes of the full prefix+canonical-json string.",
"ECIES ciphertext prefix format is Tink's standard; iOS must use Tink or a compatible library."
]
}

View File

@ -69,6 +69,43 @@ service cloud.firestore {
&& (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue)); && (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue));
} }
// Sealed-answer helpers (schemaVersion 3, partner-proof reveal).
function isSealedPayload(value) {
return value is string && value.matches('^sealed:v1:');
}
function isKeybox(value) {
return value is string && value.matches('^keybox:v1:');
}
function isCommitmentHash(value) {
return value is string && value.matches('^sha256:');
}
// Returns true when the incoming data satisfies the sealed-answer create shape.
// Plaintext content fields (writtenText, selectedOptionIds, scaleValue) are
// rejected by the hasOnly check below — they must never appear in a sealed doc.
function isSealedAnswerCreate(data) {
return data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'encryptedPayload',
'commitmentHash', 'schemaVersion', 'answerKeyReleased',
'createdAt', 'updatedAt', 'isRevealed'
])
&& isSealedPayload(data.encryptedPayload)
&& isCommitmentHash(data.commitmentHash)
&& data.schemaVersion == 3
&& data.answerKeyReleased == false
&& data.isRevealed == false;
}
// Only the reveal metadata fields may change after a sealed answer is created.
function isSealedAnswerUpdate() {
return resource.data.schemaVersion == 3
&& request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['isRevealed', '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
@ -149,6 +186,18 @@ service cloud.firestore {
match /fcmTokens/{tokenId} { match /fcmTokens/{tokenId} {
allow read, write: if isOwner(uid); allow read, write: if isOwner(uid);
} }
// 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.
match /devices/{deviceId} {
allow read: if isSignedIn();
allow create, update: if isOwner(uid)
&& request.resource.data.publicKey is string
&& request.resource.data.publicKey.matches('^pub:v1:')
&& request.resource.data.keys().hasOnly(['deviceId', 'publicKey', 'platform', 'updatedAt']);
allow delete: if false;
}
} }
// ── Date ideas (read-only catalog) ───────────────────────────────────────── // ── Date ideas (read-only catalog) ─────────────────────────────────────────
@ -427,23 +476,68 @@ service cloud.firestore {
// Daily question answers: each user writes their own; both members read. // Daily question answers: each user writes their own; both members read.
match /daily_question/{date}/answers/{userId} { match /daily_question/{date}/answers/{userId} {
allow read: if isCouplesMember(coupleId); allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId) allow create: if isCouplesMember(coupleId)
&& request.auth.uid == userId && request.auth.uid == userId
&& request.resource.data.keys().hasOnly(['userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'createdAt', 'updatedAt'])
&& request.resource.data.userId == request.auth.uid && request.resource.data.userId == request.auth.uid
&& request.resource.data.questionId is string && request.resource.data.questionId is string
&& request.resource.data.answerType is string && request.resource.data.answerType is string
&& coupleEncryptionEnabled(coupleId) && (
&& isEncryptedAnswerPayload(request.resource.data); // schemaVersion 3: partner-proof sealed answer.
isSealedAnswerCreate(request.resource.data)
||
// schemaVersion 2: couple-key encrypted answer (legacy path).
(coupleEncryptionEnabled(coupleId)
&& request.resource.data.schemaVersion == 2
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'createdAt', 'updatedAt', 'isRevealed'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
allow update: if isCouplesMember(coupleId) allow update: if isCouplesMember(coupleId)
&& request.auth.uid == userId && request.auth.uid == userId
&& request.resource.data.userId == resource.data.userId && request.resource.data.userId == resource.data.userId
&& request.resource.data.questionId == resource.data.questionId && request.resource.data.questionId == resource.data.questionId
&& request.resource.data.answerType == resource.data.answerType && request.resource.data.answerType == resource.data.answerType
&& request.resource.data.keys().hasOnly(['userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'createdAt', 'updatedAt']) && (
&& coupleEncryptionEnabled(coupleId) // Sealed answers: only reveal metadata may change; payload is immutable.
&& isEncryptedAnswerPayload(request.resource.data); isSealedAnswerUpdate()
||
// enc:v1: answers: same field set, content may be updated.
(coupleEncryptionEnabled(coupleId)
&& resource.data.schemaVersion != 3
&& request.resource.data.keys().hasOnly([
'userId', 'questionId', 'answerType', 'writtenText',
'selectedOptionIds', 'scaleValue', 'schemaVersion',
'createdAt', 'updatedAt', 'isRevealed'
])
&& isEncryptedAnswerPayload(request.resource.data))
);
allow delete: if false; allow delete: if false;
// Release keys: the sender releases their one-time answer key to the recipient
// after both partners have submitted.
match /releaseKeys/{recipientId} {
// Only the recipient can read their own release key.
allow read: if request.auth.uid == recipientId && isCouplesMember(coupleId);
// Create-only: written by the answer owner (sender) after both answers exist.
allow create: if isCouplesMember(coupleId)
&& request.auth.uid == userId
&& recipientId != userId
&& recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
&& exists(/databases/$(database)/documents/couples/$(coupleId)/daily_question/$(date)/answers/$(recipientId))
&& isKeybox(request.resource.data.encryptedAnswerKey)
&& request.resource.data.recipientUserId == recipientId
&& request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
allow update: if false;
allow delete: if false;
}
} }
match /{gameCollection}/{sessionId} { match /{gameCollection}/{sessionId} {