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 5b3e31fbae
commit e027b7a687
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_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.notifications.NotificationChannelSetup
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.hybrid.HybridConfig
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -24,6 +25,7 @@ class CloserApp : Application() {
override fun onCreate() {
super.onCreate()
AeadConfig.register()
HybridConfig.register()
ActivityProvider.register(this)
NotificationChannelSetup.createChannels(applicationContext)
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
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.LocalAnswer
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.suspendCancellableCoroutine
@ -28,7 +32,11 @@ import kotlin.coroutines.resumeWithException
class FirestoreAnswerDataSource @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 dailyQuestionRef(coupleId: String, date: String) =
@ -41,8 +49,9 @@ class FirestoreAnswerDataSource @Inject constructor(
dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
/**
* Persists the user's answer to Firestore. Creates or overwrites the answer
* document at `answers/{userId}`.
* Persists the user's answer to Firestore. Uses sealed:v1: format (schemaVersion 3)
* 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(
coupleId: String,
@ -50,6 +59,62 @@ class FirestoreAnswerDataSource @Inject constructor(
userId: String,
answer: LocalAnswer,
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 ->
val aead = encryptionManager.requireAead(coupleId)
val data = mapOf(
@ -63,6 +128,8 @@ class FirestoreAnswerDataSource @Inject constructor(
"scaleValue" to if (answer.scaleValue != null)
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
else answer.scaleValue,
"schemaVersion" to 2,
"answerDate" to date,
"createdAt" to answer.createdAt,
"updatedAt" to answer.updatedAt,
"isRevealed" to answer.isRevealed
@ -138,8 +205,28 @@ class FirestoreAnswerDataSource @Inject constructor(
aead: com.google.crypto.tink.Aead?,
coupleId: String
): 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()
// selectedOptionIds is stored as a single encrypted JSON blob OR a plaintext list
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
if (decrypted != null) runCatching {
@ -169,7 +256,9 @@ class FirestoreAnswerDataSource @Inject constructor(
scaleValue = scaleValue,
createdAt = getLong("createdAt") ?: 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 FCM_TOKENS = "fcmTokens"
const val ENTITLEMENT_PREMIUM_DOC = "premium"
const val DEVICES = "devices"
const val DEVICE_PRIMARY = "primary"
}
// ── Subcollections under couples/{coupleId} ───────────────────────────────
@ -43,6 +45,11 @@ object FirestoreCollections {
const val ANSWERS = "answers"
}
// ── Subcollections under …/daily_question/{date}/answers/{userId} ─────────
object Answers {
const val RELEASE_KEYS = "releaseKeys"
}
// ── Subcollections under …/question_threads/{threadId} ────────────────────
object QuestionThreads {
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 createdAt: 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.refreshPartnerAnswer()
},
onRefresh = viewModel::refreshPartnerAnswer,
onAnswerQuestion = {
val coupleId = state.coupleId
if (coupleId != null) {
@ -94,6 +95,7 @@ private fun AnswerRevealContent(
onAnswerQuestion: () -> Unit,
onHistory: () -> Unit,
onHome: () -> Unit,
onRefresh: () -> Unit = {},
onFollowUpSelected: (FollowUpOption) -> Unit = {},
onSnackbarShown: () -> Unit = {}
) {
@ -151,6 +153,22 @@ private fun AnswerRevealContent(
onAnswerQuestion = onAnswerQuestion,
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(
answer = state.answer,
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
private fun RevealedState(
answer: LocalAnswer,

View File

@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.core.navigation.AppRoute
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.domain.model.LocalAnswer
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?")
}
/**
* 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(
val isLoading: Boolean = true,
val error: String? = null,
@ -36,7 +60,8 @@ data class AnswerRevealUiState(
val coupleId: String? = null,
val partnerId: String? = null,
val followUpOptions: List<FollowUpOption> = emptyList(),
val snackbarMessage: String? = null
val snackbarMessage: String? = null,
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE
)
@HiltViewModel
@ -47,6 +72,8 @@ class AnswerRevealViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val crashReporter: CrashReporter,
private val sealedRevealManager: SealedRevealManager,
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
savedStateHandle: SavedStateHandle
) : ViewModel() {
@ -72,12 +99,14 @@ class AnswerRevealViewModel @Inject constructor(
firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId,
userId = partnerId,
date = FirestoreAnswerDataSource.todayLocalDateString()
date = effectiveDate(answer)
)
}.onFailure { crashReporter.recordException(it) }.getOrNull()
} else null
val sealedPhase = computeSealedPhase(answer, partnerAnswer)
val category = answer?.category ?: question?.category ?: ""
_uiState.value = AnswerRevealUiState(
isLoading = false,
question = question,
@ -85,6 +114,7 @@ class AnswerRevealViewModel @Inject constructor(
partnerAnswer = partnerAnswer,
coupleId = coupleId,
partnerId = partnerId,
sealedRevealPhase = sealedPhase,
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
)
} catch (e: Exception) {
@ -97,9 +127,13 @@ class AnswerRevealViewModel @Inject constructor(
}
}
/**
* Resolves the current user's couple and partner IDs.
*/
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
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?> {
val userId = authRepository.currentUserId ?: return null to null
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }
@ -123,35 +157,123 @@ class AnswerRevealViewModel @Inject constructor(
val coupleId = state.coupleId ?: return
val partnerId = state.partnerId ?: return
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 {
firestoreAnswerDataSource.getAnswerForUser(
coupleId = coupleId,
userId = partnerId,
date = FirestoreAnswerDataSource.todayLocalDateString()
date = effectiveDate(state.answer)
)
}.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() {
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 {
localAnswerRepository.markRevealed(questionId)
val answer = localAnswerRepository.getAnswer(questionId)
val partnerAnswer = _uiState.value.partnerAnswer
val category = answer?.category ?: _uiState.value.question?.category ?: ""
_uiState.update {
it.copy(
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
)
it.copy(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(
answer: LocalAnswer?,
partnerAnswer: LocalAnswer?,
@ -190,4 +312,8 @@ class AnswerRevealViewModel @Inject constructor(
fun clearSnackbar() {
_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 app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus
import app.closer.crypto.SealedRevealManager
import app.closer.data.remote.FirestoreAnswerDataSource
import app.closer.data.remote.FirestoreCapsuleDataSource
import app.closer.data.remote.FirestoreChallengeDataSource
@ -141,7 +142,8 @@ class HomeViewModel @Inject constructor(
private val questionSessionRepository: QuestionSessionRepository,
private val challengeDataSource: FirestoreChallengeDataSource,
private val capsuleDataSource: FirestoreCapsuleDataSource,
private val datePlanRepository: DatePlanRepository
private val datePlanRepository: DatePlanRepository,
private val sealedRevealManager: SealedRevealManager
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
@ -178,6 +180,7 @@ class HomeViewModel @Inject constructor(
)
}
val uid = authRepository.currentUserId
uid?.let { launch { runCatching { sealedRevealManager.ensurePublicKeyPublished(it) } } }
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId ->
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));
}
// 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() {
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
&& request.resource.data.encryptionVersion == 1
@ -149,6 +186,18 @@ service cloud.firestore {
match /fcmTokens/{tokenId} {
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) ─────────────────────────────────────────
@ -427,23 +476,68 @@ service cloud.firestore {
// Daily question answers: each user writes their own; both members read.
match /daily_question/{date}/answers/{userId} {
allow read: if isCouplesMember(coupleId);
allow create: if isCouplesMember(coupleId)
&& 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.questionId 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)
&& request.auth.uid == userId
&& request.resource.data.userId == resource.data.userId
&& request.resource.data.questionId == resource.data.questionId
&& request.resource.data.answerType == resource.data.answerType
&& request.resource.data.keys().hasOnly(['userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'createdAt', 'updatedAt'])
&& coupleEncryptionEnabled(coupleId)
&& isEncryptedAnswerPayload(request.resource.data);
&& (
// Sealed answers: only reveal metadata may change; payload is immutable.
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;
// 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} {