From a3993d08df3ed78735d9a8d1615e25c199334465 Mon Sep 17 00:00:00 2001 From: null Date: Sat, 20 Jun 2026 00:23:58 -0500 Subject: [PATCH] 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 --- .gitignore | 1 + app/src/main/java/app/closer/CloserApp.kt | 2 + .../app/closer/crypto/AnswerCommitment.kt | 68 ++++++ .../closer/crypto/PendingAnswerKeyStore.kt | 48 +++++ .../app/closer/crypto/ReleaseKeyEncryptor.kt | 70 +++++++ .../closer/crypto/SealedAnswerEncryptor.kt | 143 +++++++++++++ .../app/closer/crypto/SealedRevealManager.kt | 122 +++++++++++ .../java/app/closer/crypto/UserKeyManager.kt | 99 +++++++++ .../data/remote/FirestoreAnswerDataSource.kt | 99 ++++++++- .../data/remote/FirestoreCollections.kt | 7 + .../remote/FirestoreDeviceKeyDataSource.kt | 44 ++++ .../remote/FirestoreReleaseKeyDataSource.kt | 73 +++++++ .../app/closer/domain/model/LocalAnswer.kt | 10 +- .../closer/ui/answers/AnswerRevealScreen.kt | 197 ++++++++++++++++++ .../ui/answers/AnswerRevealViewModel.kt | 154 ++++++++++++-- .../java/app/closer/ui/home/HomeViewModel.kt | 5 +- .../app/closer/crypto/AnswerCommitmentTest.kt | 69 ++++++ .../closer/crypto/ReleaseKeyEncryptorTest.kt | 91 ++++++++ .../crypto/SealedAnswerEncryptorTest.kt | 130 ++++++++++++ docs/crypto/sealed-answer-test-vectors.json | 80 +++++++ firestore.rules | 106 +++++++++- 21 files changed, 1591 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/app/closer/crypto/AnswerCommitment.kt create mode 100644 app/src/main/java/app/closer/crypto/PendingAnswerKeyStore.kt create mode 100644 app/src/main/java/app/closer/crypto/ReleaseKeyEncryptor.kt create mode 100644 app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt create mode 100644 app/src/main/java/app/closer/crypto/SealedRevealManager.kt create mode 100644 app/src/main/java/app/closer/crypto/UserKeyManager.kt create mode 100644 app/src/main/java/app/closer/data/remote/FirestoreDeviceKeyDataSource.kt create mode 100644 app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt create mode 100644 app/src/test/java/app/closer/crypto/AnswerCommitmentTest.kt create mode 100644 app/src/test/java/app/closer/crypto/ReleaseKeyEncryptorTest.kt create mode 100644 app/src/test/java/app/closer/crypto/SealedAnswerEncryptorTest.kt create mode 100644 docs/crypto/sealed-answer-test-vectors.json diff --git a/.gitignore b/.gitignore index 6fad3718..bfd5ca1d 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ UI-PLAN.md # Build plans (agent-only, never commit) *_build_plan.md +closer_partner_proof_reveal_privacy.md diff --git a/app/src/main/java/app/closer/CloserApp.kt b/app/src/main/java/app/closer/CloserApp.kt index 51c061dd..572dac0d 100644 --- a/app/src/main/java/app/closer/CloserApp.kt +++ b/app/src/main/java/app/closer/CloserApp.kt @@ -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() diff --git a/app/src/main/java/app/closer/crypto/AnswerCommitment.kt b/app/src/main/java/app/closer/crypto/AnswerCommitment.kt new file mode 100644 index 00000000..989819ee --- /dev/null +++ b/app/src/main/java/app/closer/crypto/AnswerCommitment.kt @@ -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, + 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, + 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:" + } +} diff --git a/app/src/main/java/app/closer/crypto/PendingAnswerKeyStore.kt b/app/src/main/java/app/closer/crypto/PendingAnswerKeyStore.kt new file mode 100644 index 00000000..510c9ee1 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/PendingAnswerKeyStore.kt @@ -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" + } +} diff --git a/app/src/main/java/app/closer/crypto/ReleaseKeyEncryptor.kt b/app/src/main/java/app/closer/crypto/ReleaseKeyEncryptor.kt new file mode 100644 index 00000000..b9cbf575 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/ReleaseKeyEncryptor.kt @@ -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:" + } +} diff --git a/app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt b/app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt new file mode 100644 index 00000000..6c4e53e7 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/SealedAnswerEncryptor.kt @@ -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, + 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 { + 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:" + } +} diff --git a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt new file mode 100644 index 00000000..ddff97a4 --- /dev/null +++ b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt @@ -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) + } +} diff --git a/app/src/main/java/app/closer/crypto/UserKeyManager.kt b/app/src/main/java/app/closer/crypto/UserKeyManager.kt new file mode 100644 index 00000000..e36b74dc --- /dev/null +++ b/app/src/main/java/app/closer/crypto/UserKeyManager.kt @@ -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()) + } + } +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt index 24614a91..9558d685 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreAnswerDataSource.kt @@ -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 ?: 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") ?: "" ) } diff --git a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt index 2c1d18c6..f7175ead 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreCollections.kt @@ -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" diff --git a/app/src/main/java/app/closer/data/remote/FirestoreDeviceKeyDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreDeviceKeyDataSource.kt new file mode 100644 index 00000000..3ac6b858 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreDeviceKeyDataSource.kt @@ -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) +} diff --git a/app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt new file mode 100644 index 00000000..1629a317 --- /dev/null +++ b/app/src/main/java/app/closer/data/remote/FirestoreReleaseKeyDataSource.kt @@ -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) +} diff --git a/app/src/main/java/app/closer/domain/model/LocalAnswer.kt b/app/src/main/java/app/closer/domain/model/LocalAnswer.kt index 51b7bc39..7849a4c2 100644 --- a/app/src/main/java/app/closer/domain/model/LocalAnswer.kt +++ b/app/src/main/java/app/closer/domain/model/LocalAnswer.kt @@ -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 = "" ) diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt index e457e8e0..d003dca2 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealScreen.kt @@ -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, diff --git a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt index 5b2a2ec7..2fe958e9 100644 --- a/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt +++ b/app/src/main/java/app/closer/ui/answers/AnswerRevealViewModel.kt @@ -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 = 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 { 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() } diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 0afab605..ee21b765 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -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 } diff --git a/app/src/test/java/app/closer/crypto/AnswerCommitmentTest.kt b/app/src/test/java/app/closer/crypto/AnswerCommitmentTest.kt new file mode 100644 index 00000000..b8fbe5ff --- /dev/null +++ b/app/src/test/java/app/closer/crypto/AnswerCommitmentTest.kt @@ -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\\\"")) + } +} diff --git a/app/src/test/java/app/closer/crypto/ReleaseKeyEncryptorTest.kt b/app/src/test/java/app/closer/crypto/ReleaseKeyEncryptorTest.kt new file mode 100644 index 00000000..abd08363 --- /dev/null +++ b/app/src/test/java/app/closer/crypto/ReleaseKeyEncryptorTest.kt @@ -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) + } +} diff --git a/app/src/test/java/app/closer/crypto/SealedAnswerEncryptorTest.kt b/app/src/test/java/app/closer/crypto/SealedAnswerEncryptorTest.kt new file mode 100644 index 00000000..807ba086 --- /dev/null +++ b/app/src/test/java/app/closer/crypto/SealedAnswerEncryptorTest.kt @@ -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(), 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) + } +} diff --git a/docs/crypto/sealed-answer-test-vectors.json b/docs/crypto/sealed-answer-test-vectors.json new file mode 100644 index 00000000..16b8bc5b --- /dev/null +++ b/docs/crypto/sealed-answer-test-vectors.json @@ -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." + ] +} diff --git a/firestore.rules b/firestore.rules index 691a5a91..f85a5e9b 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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} {