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:
parent
521989ec44
commit
a3993d08df
|
|
@ -50,3 +50,4 @@ UI-PLAN.md
|
||||||
|
|
||||||
# Build plans (agent-only, never commit)
|
# Build plans (agent-only, never commit)
|
||||||
*_build_plan.md
|
*_build_plan.md
|
||||||
|
closer_partner_proof_reveal_privacy.md
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import app.closer.data.repository.ActivityProvider
|
||||||
import app.closer.domain.security.DeviceIntegrityChecker
|
import app.closer.domain.security.DeviceIntegrityChecker
|
||||||
import app.closer.notifications.NotificationChannelSetup
|
import app.closer.notifications.NotificationChannelSetup
|
||||||
import com.google.crypto.tink.aead.AeadConfig
|
import com.google.crypto.tink.aead.AeadConfig
|
||||||
|
import com.google.crypto.tink.hybrid.HybridConfig
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
|
@ -24,6 +25,7 @@ class CloserApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
AeadConfig.register()
|
AeadConfig.register()
|
||||||
|
HybridConfig.register()
|
||||||
ActivityProvider.register(this)
|
ActivityProvider.register(this)
|
||||||
NotificationChannelSetup.createChannels(applicationContext)
|
NotificationChannelSetup.createChannels(applicationContext)
|
||||||
firebaseInitializer.initialize()
|
firebaseInitializer.initialize()
|
||||||
|
|
|
||||||
|
|
@ -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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package app.closer.data.remote
|
package app.closer.data.remote
|
||||||
|
|
||||||
|
import app.closer.crypto.AnswerCommitment
|
||||||
import app.closer.crypto.CoupleEncryptionManager
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
import app.closer.crypto.FieldEncryptor
|
import app.closer.crypto.FieldEncryptor
|
||||||
|
import app.closer.crypto.PendingAnswerKeyStore
|
||||||
|
import app.closer.crypto.SealedAnswerEncryptor
|
||||||
|
import app.closer.crypto.UserKeyManager
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import com.google.firebase.firestore.FirebaseFirestore
|
import com.google.firebase.firestore.FirebaseFirestore
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
|
@ -28,7 +32,11 @@ import kotlin.coroutines.resumeWithException
|
||||||
class FirestoreAnswerDataSource @Inject constructor(
|
class FirestoreAnswerDataSource @Inject constructor(
|
||||||
private val db: FirebaseFirestore,
|
private val db: FirebaseFirestore,
|
||||||
private val encryptionManager: CoupleEncryptionManager,
|
private val encryptionManager: CoupleEncryptionManager,
|
||||||
private val fieldEncryptor: FieldEncryptor
|
private val fieldEncryptor: FieldEncryptor,
|
||||||
|
private val userKeyManager: UserKeyManager,
|
||||||
|
private val sealedAnswerEncryptor: SealedAnswerEncryptor,
|
||||||
|
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
||||||
|
private val answerCommitment: AnswerCommitment
|
||||||
) {
|
) {
|
||||||
|
|
||||||
private fun dailyQuestionRef(coupleId: String, date: String) =
|
private fun dailyQuestionRef(coupleId: String, date: String) =
|
||||||
|
|
@ -41,8 +49,9 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
|
dailyQuestionRef(coupleId, date).collection(FirestoreCollections.DailyQuestion.ANSWERS).document(userId)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists the user's answer to Firestore. Creates or overwrites the answer
|
* Persists the user's answer to Firestore. Uses sealed:v1: format (schemaVersion 3)
|
||||||
* document at `answers/{userId}`.
|
* when the user has an ECIES keypair, giving partner-proof privacy. Falls back to
|
||||||
|
* enc:v1: format (schemaVersion 2) if no keypair is present yet.
|
||||||
*/
|
*/
|
||||||
suspend fun saveAnswer(
|
suspend fun saveAnswer(
|
||||||
coupleId: String,
|
coupleId: String,
|
||||||
|
|
@ -50,6 +59,62 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
userId: String,
|
userId: String,
|
||||||
answer: LocalAnswer,
|
answer: LocalAnswer,
|
||||||
date: String = todayLocalDateString()
|
date: String = todayLocalDateString()
|
||||||
|
): Unit {
|
||||||
|
val privateKey = userKeyManager.loadPrivateKey()
|
||||||
|
if (privateKey != null) {
|
||||||
|
saveAnswerSealed(coupleId, questionId, userId, answer, date)
|
||||||
|
} else {
|
||||||
|
saveAnswerEncrypted(coupleId, questionId, userId, answer, date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveAnswerSealed(
|
||||||
|
coupleId: String,
|
||||||
|
questionId: String,
|
||||||
|
userId: String,
|
||||||
|
answer: LocalAnswer,
|
||||||
|
date: String
|
||||||
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
|
val oneTimeKey = sealedAnswerEncryptor.generateOneTimeKey()
|
||||||
|
val payload = SealedAnswerEncryptor.AnswerPayload(
|
||||||
|
writtenText = answer.writtenText,
|
||||||
|
selectedOptionIds = answer.selectedOptionIds,
|
||||||
|
scaleValue = answer.scaleValue
|
||||||
|
)
|
||||||
|
val encryptedPayload = sealedAnswerEncryptor.seal(payload, oneTimeKey, coupleId, questionId, userId)
|
||||||
|
val commitment = answerCommitment.compute(
|
||||||
|
coupleId, questionId, userId,
|
||||||
|
answer.writtenText, answer.selectedOptionIds, answer.scaleValue
|
||||||
|
)
|
||||||
|
|
||||||
|
pendingAnswerKeyStore.store(questionId, oneTimeKey)
|
||||||
|
|
||||||
|
val data = mapOf(
|
||||||
|
"userId" to userId,
|
||||||
|
"questionId" to questionId,
|
||||||
|
"answerType" to answer.answerType,
|
||||||
|
"encryptedPayload" to encryptedPayload,
|
||||||
|
"commitmentHash" to commitment,
|
||||||
|
"schemaVersion" to 3,
|
||||||
|
"answerKeyReleased" to false,
|
||||||
|
"answerDate" to date,
|
||||||
|
"createdAt" to answer.createdAt,
|
||||||
|
"updatedAt" to answer.updatedAt,
|
||||||
|
"isRevealed" to answer.isRevealed
|
||||||
|
)
|
||||||
|
|
||||||
|
answerRef(coupleId, date, userId)
|
||||||
|
.set(data)
|
||||||
|
.addOnSuccessListener { cont.resume(Unit) }
|
||||||
|
.addOnFailureListener { cont.resumeWithException(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun saveAnswerEncrypted(
|
||||||
|
coupleId: String,
|
||||||
|
questionId: String,
|
||||||
|
userId: String,
|
||||||
|
answer: LocalAnswer,
|
||||||
|
date: String
|
||||||
): Unit = suspendCancellableCoroutine { cont ->
|
): Unit = suspendCancellableCoroutine { cont ->
|
||||||
val aead = encryptionManager.requireAead(coupleId)
|
val aead = encryptionManager.requireAead(coupleId)
|
||||||
val data = mapOf(
|
val data = mapOf(
|
||||||
|
|
@ -63,6 +128,8 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
"scaleValue" to if (answer.scaleValue != null)
|
"scaleValue" to if (answer.scaleValue != null)
|
||||||
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
|
||||||
else answer.scaleValue,
|
else answer.scaleValue,
|
||||||
|
"schemaVersion" to 2,
|
||||||
|
"answerDate" to date,
|
||||||
"createdAt" to answer.createdAt,
|
"createdAt" to answer.createdAt,
|
||||||
"updatedAt" to answer.updatedAt,
|
"updatedAt" to answer.updatedAt,
|
||||||
"isRevealed" to answer.isRevealed
|
"isRevealed" to answer.isRevealed
|
||||||
|
|
@ -138,8 +205,28 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
aead: com.google.crypto.tink.Aead?,
|
aead: com.google.crypto.tink.Aead?,
|
||||||
coupleId: String
|
coupleId: String
|
||||||
): LocalAnswer {
|
): LocalAnswer {
|
||||||
|
val schemaVersion = getLong("schemaVersion")?.toInt() ?: 2
|
||||||
|
|
||||||
|
// schemaVersion 3: sealed:v1: — content is in encryptedPayload, not top-level fields.
|
||||||
|
// The calling code (reveal flow) is responsible for decrypting via SealedRevealManager.
|
||||||
|
if (schemaVersion == 3) {
|
||||||
|
return LocalAnswer(
|
||||||
|
questionId = getString("questionId") ?: "",
|
||||||
|
questionText = "",
|
||||||
|
category = "",
|
||||||
|
answerType = getString("answerType") ?: "written",
|
||||||
|
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
||||||
|
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
||||||
|
isRevealed = getBoolean("isRevealed") ?: false,
|
||||||
|
schemaVersion = 3,
|
||||||
|
isSealed = getBoolean("answerKeyReleased") != true,
|
||||||
|
encryptedPayload = getString("encryptedPayload"),
|
||||||
|
answerDate = getString("answerDate") ?: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// schemaVersion 2: enc:v1: — decrypt with couple AEAD.
|
||||||
val rawIds = get("selectedOptionIds") as? List<String> ?: emptyList()
|
val rawIds = get("selectedOptionIds") as? List<String> ?: emptyList()
|
||||||
// selectedOptionIds is stored as a single encrypted JSON blob OR a plaintext list
|
|
||||||
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
|
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
|
||||||
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
|
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
|
||||||
if (decrypted != null) runCatching {
|
if (decrypted != null) runCatching {
|
||||||
|
|
@ -169,7 +256,9 @@ class FirestoreAnswerDataSource @Inject constructor(
|
||||||
scaleValue = scaleValue,
|
scaleValue = scaleValue,
|
||||||
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
|
||||||
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
|
||||||
isRevealed = getBoolean("isRevealed") ?: false
|
isRevealed = getBoolean("isRevealed") ?: false,
|
||||||
|
schemaVersion = schemaVersion,
|
||||||
|
answerDate = getString("answerDate") ?: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ object FirestoreCollections {
|
||||||
const val ENTITLEMENTS = "entitlements"
|
const val ENTITLEMENTS = "entitlements"
|
||||||
const val FCM_TOKENS = "fcmTokens"
|
const val FCM_TOKENS = "fcmTokens"
|
||||||
const val ENTITLEMENT_PREMIUM_DOC = "premium"
|
const val ENTITLEMENT_PREMIUM_DOC = "premium"
|
||||||
|
const val DEVICES = "devices"
|
||||||
|
const val DEVICE_PRIMARY = "primary"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subcollections under couples/{coupleId} ───────────────────────────────
|
// ── Subcollections under couples/{coupleId} ───────────────────────────────
|
||||||
|
|
@ -43,6 +45,11 @@ object FirestoreCollections {
|
||||||
const val ANSWERS = "answers"
|
const val ANSWERS = "answers"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Subcollections under …/daily_question/{date}/answers/{userId} ─────────
|
||||||
|
object Answers {
|
||||||
|
const val RELEASE_KEYS = "releaseKeys"
|
||||||
|
}
|
||||||
|
|
||||||
// ── Subcollections under …/question_threads/{threadId} ────────────────────
|
// ── Subcollections under …/question_threads/{threadId} ────────────────────
|
||||||
object QuestionThreads {
|
object QuestionThreads {
|
||||||
const val ANSWERS = "answers"
|
const val ANSWERS = "answers"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -11,5 +11,13 @@ data class LocalAnswer(
|
||||||
val scaleValue: Int? = null,
|
val scaleValue: Int? = null,
|
||||||
val createdAt: Long = System.currentTimeMillis(),
|
val createdAt: Long = System.currentTimeMillis(),
|
||||||
val updatedAt: Long = System.currentTimeMillis(),
|
val updatedAt: Long = System.currentTimeMillis(),
|
||||||
val isRevealed: Boolean = false
|
val isRevealed: Boolean = false,
|
||||||
|
// schemaVersion 2 = enc:v1: (couple-key); 3 = sealed:v1: (partner-proof)
|
||||||
|
val schemaVersion: Int = 2,
|
||||||
|
// true while a sealed answer has been submitted but the reveal key has not been released
|
||||||
|
val isSealed: Boolean = false,
|
||||||
|
// raw sealed payload retained so the reveal flow can decrypt it later
|
||||||
|
val encryptedPayload: String? = null,
|
||||||
|
// YYYY-MM-DD string matching the daily_question/{date} document key — required for release key path
|
||||||
|
val answerDate: String = ""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ fun AnswerRevealScreen(
|
||||||
viewModel.revealAnswer()
|
viewModel.revealAnswer()
|
||||||
viewModel.refreshPartnerAnswer()
|
viewModel.refreshPartnerAnswer()
|
||||||
},
|
},
|
||||||
|
onRefresh = viewModel::refreshPartnerAnswer,
|
||||||
onAnswerQuestion = {
|
onAnswerQuestion = {
|
||||||
val coupleId = state.coupleId
|
val coupleId = state.coupleId
|
||||||
if (coupleId != null) {
|
if (coupleId != null) {
|
||||||
|
|
@ -94,6 +95,7 @@ private fun AnswerRevealContent(
|
||||||
onAnswerQuestion: () -> Unit,
|
onAnswerQuestion: () -> Unit,
|
||||||
onHistory: () -> Unit,
|
onHistory: () -> Unit,
|
||||||
onHome: () -> Unit,
|
onHome: () -> Unit,
|
||||||
|
onRefresh: () -> Unit = {},
|
||||||
onFollowUpSelected: (FollowUpOption) -> Unit = {},
|
onFollowUpSelected: (FollowUpOption) -> Unit = {},
|
||||||
onSnackbarShown: () -> Unit = {}
|
onSnackbarShown: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
|
|
@ -151,6 +153,22 @@ private fun AnswerRevealContent(
|
||||||
onAnswerQuestion = onAnswerQuestion,
|
onAnswerQuestion = onAnswerQuestion,
|
||||||
onHome = onHome
|
onHome = onHome
|
||||||
)
|
)
|
||||||
|
// Sealed reveal phases (schemaVersion 3) — checked before isRevealed
|
||||||
|
state.sealedRevealPhase == SealedRevealPhase.RELEASING_KEY ->
|
||||||
|
ReleasingKeyState()
|
||||||
|
state.sealedRevealPhase == SealedRevealPhase.WAITING_FOR_PARTNER ->
|
||||||
|
WaitingForPartnerState(onRefresh = onRefresh, onHome = onHome)
|
||||||
|
state.sealedRevealPhase == SealedRevealPhase.LOST_LOCAL_KEY ->
|
||||||
|
LostLocalKeyState(onHome = onHome)
|
||||||
|
state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED ->
|
||||||
|
BothAnsweredSealedState(
|
||||||
|
question = state.question,
|
||||||
|
onReveal = onReveal,
|
||||||
|
onHistory = onHistory
|
||||||
|
)
|
||||||
|
state.sealedRevealPhase == SealedRevealPhase.ANSWER_SEALED ->
|
||||||
|
AnswerSealedState(question = state.question, onHome = onHome)
|
||||||
|
// Legacy (enc:v1:) reveal path
|
||||||
!state.answer.isRevealed -> ReadyToRevealState(
|
!state.answer.isRevealed -> ReadyToRevealState(
|
||||||
answer = state.answer,
|
answer = state.answer,
|
||||||
partnerAnswer = state.partnerAnswer,
|
partnerAnswer = state.partnerAnswer,
|
||||||
|
|
@ -300,6 +318,185 @@ private fun ReadyToRevealState(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sealed-answer reveal states (Batch 11) ───────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AnswerSealedState(
|
||||||
|
question: Question?,
|
||||||
|
onHome: () -> Unit
|
||||||
|
) {
|
||||||
|
RevealMessageCard {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
RevealPill("Your answer is sealed")
|
||||||
|
Text(
|
||||||
|
text = question?.text ?: "Your answer is private.",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Waiting for your partner. Reveal opens once both of you have answered.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onHome,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Back to home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BothAnsweredSealedState(
|
||||||
|
question: Question?,
|
||||||
|
onReveal: () -> Unit,
|
||||||
|
onHistory: () -> Unit
|
||||||
|
) {
|
||||||
|
RevealMessageCard {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
RevealPill("Both answers are in")
|
||||||
|
Text(
|
||||||
|
text = question?.text ?: "Both of you answered.",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Reveal is ready. Open it when you're both here — answers are exchanged privately between your devices.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onReveal,
|
||||||
|
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
contentColor = Color(0xFF24122F)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Reveal answer")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onHistory,
|
||||||
|
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Saved answers")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReleasingKeyState() {
|
||||||
|
RevealMessageCard {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp)
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(color = Color(0xFFB98AF4))
|
||||||
|
Text(
|
||||||
|
text = "Opening reveal…",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun WaitingForPartnerState(
|
||||||
|
onRefresh: () -> Unit,
|
||||||
|
onHome: () -> Unit
|
||||||
|
) {
|
||||||
|
RevealMessageCard {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
RevealPill("Almost there")
|
||||||
|
Text(
|
||||||
|
text = "Waiting for your partner to finish reveal.",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Your answer key is released. Once your partner opens their reveal, both answers will appear here.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 4,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Button(
|
||||||
|
onClick = onRefresh,
|
||||||
|
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFB98AF4),
|
||||||
|
contentColor = Color(0xFF24122F)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Check again")
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onHome,
|
||||||
|
modifier = Modifier.weight(1f).heightIn(min = 48.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Back to home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LostLocalKeyState(onHome: () -> Unit) {
|
||||||
|
RevealMessageCard {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
RevealPill("Reveal unavailable")
|
||||||
|
Text(
|
||||||
|
text = "This answer cannot be revealed from this device.",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "The sealed answer key was stored on the device you originally answered on. Open the app on that device to complete the reveal.",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 5,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onHome,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
) {
|
||||||
|
Text("Back to home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RevealedState(
|
private fun RevealedState(
|
||||||
answer: LocalAnswer,
|
answer: LocalAnswer,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.core.navigation.AppRoute
|
import app.closer.core.navigation.AppRoute
|
||||||
import app.closer.core.crash.CrashReporter
|
import app.closer.core.crash.CrashReporter
|
||||||
|
import app.closer.crypto.PendingAnswerKeyStore
|
||||||
|
import app.closer.crypto.SealedRevealManager
|
||||||
import app.closer.data.remote.FirestoreAnswerDataSource
|
import app.closer.data.remote.FirestoreAnswerDataSource
|
||||||
import app.closer.domain.model.LocalAnswer
|
import app.closer.domain.model.LocalAnswer
|
||||||
import app.closer.domain.model.Question
|
import app.closer.domain.model.Question
|
||||||
|
|
@ -27,6 +29,28 @@ enum class FollowUpOption(val label: String, val route: String? = null) {
|
||||||
ANOTHER_QUESTION("Want to try another question from this category?")
|
ANOTHER_QUESTION("Want to try another question from this category?")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks where we are in the sealed-answer reveal exchange.
|
||||||
|
*
|
||||||
|
* State machine:
|
||||||
|
* NONE — answer is enc:v1: (schemaVersion 2); use existing non-sealed reveal path
|
||||||
|
* ANSWER_SEALED — own answer submitted; partner has not answered yet
|
||||||
|
* BOTH_ANSWERED — both answers submitted; "Reveal is ready" but keys not released yet
|
||||||
|
* RELEASING_KEY — writing our one-time key to Firestore (in-flight)
|
||||||
|
* WAITING_FOR_PARTNER — our key released; partner has not released theirs yet
|
||||||
|
* REVEALED — both keys released, partner answer decrypted and visible
|
||||||
|
* LOST_LOCAL_KEY — the pending answer key is missing from this device (unrecoverable)
|
||||||
|
*/
|
||||||
|
enum class SealedRevealPhase {
|
||||||
|
NONE,
|
||||||
|
ANSWER_SEALED,
|
||||||
|
BOTH_ANSWERED,
|
||||||
|
RELEASING_KEY,
|
||||||
|
WAITING_FOR_PARTNER,
|
||||||
|
REVEALED,
|
||||||
|
LOST_LOCAL_KEY
|
||||||
|
}
|
||||||
|
|
||||||
data class AnswerRevealUiState(
|
data class AnswerRevealUiState(
|
||||||
val isLoading: Boolean = true,
|
val isLoading: Boolean = true,
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
|
|
@ -36,7 +60,8 @@ data class AnswerRevealUiState(
|
||||||
val coupleId: String? = null,
|
val coupleId: String? = null,
|
||||||
val partnerId: String? = null,
|
val partnerId: String? = null,
|
||||||
val followUpOptions: List<FollowUpOption> = emptyList(),
|
val followUpOptions: List<FollowUpOption> = emptyList(),
|
||||||
val snackbarMessage: String? = null
|
val snackbarMessage: String? = null,
|
||||||
|
val sealedRevealPhase: SealedRevealPhase = SealedRevealPhase.NONE
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -47,6 +72,8 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
private val coupleRepository: CoupleRepository,
|
private val coupleRepository: CoupleRepository,
|
||||||
private val crashReporter: CrashReporter,
|
private val crashReporter: CrashReporter,
|
||||||
|
private val sealedRevealManager: SealedRevealManager,
|
||||||
|
private val pendingAnswerKeyStore: PendingAnswerKeyStore,
|
||||||
savedStateHandle: SavedStateHandle
|
savedStateHandle: SavedStateHandle
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
|
|
@ -72,12 +99,14 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
firestoreAnswerDataSource.getAnswerForUser(
|
firestoreAnswerDataSource.getAnswerForUser(
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
userId = partnerId,
|
userId = partnerId,
|
||||||
date = FirestoreAnswerDataSource.todayLocalDateString()
|
date = effectiveDate(answer)
|
||||||
)
|
)
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
|
val sealedPhase = computeSealedPhase(answer, partnerAnswer)
|
||||||
val category = answer?.category ?: question?.category ?: ""
|
val category = answer?.category ?: question?.category ?: ""
|
||||||
|
|
||||||
_uiState.value = AnswerRevealUiState(
|
_uiState.value = AnswerRevealUiState(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
question = question,
|
question = question,
|
||||||
|
|
@ -85,6 +114,7 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
partnerAnswer = partnerAnswer,
|
partnerAnswer = partnerAnswer,
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
partnerId = partnerId,
|
partnerId = partnerId,
|
||||||
|
sealedRevealPhase = sealedPhase,
|
||||||
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
|
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -97,9 +127,13 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun computeSealedPhase(answer: LocalAnswer?, partnerAnswer: LocalAnswer?): SealedRevealPhase {
|
||||||
* Resolves the current user's couple and partner IDs.
|
if (answer?.schemaVersion != 3) return SealedRevealPhase.NONE
|
||||||
*/
|
if (answer.isRevealed) return SealedRevealPhase.REVEALED
|
||||||
|
if (!pendingAnswerKeyStore.hasPendingKey(questionId)) return SealedRevealPhase.LOST_LOCAL_KEY
|
||||||
|
return if (partnerAnswer != null) SealedRevealPhase.BOTH_ANSWERED else SealedRevealPhase.ANSWER_SEALED
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun resolveCoupleAndPartner(): Pair<String?, String?> {
|
private suspend fun resolveCoupleAndPartner(): Pair<String?, String?> {
|
||||||
val userId = authRepository.currentUserId ?: return null to null
|
val userId = authRepository.currentUserId ?: return null to null
|
||||||
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }
|
val couple = runCatching { coupleRepository.getCoupleForUser(userId) }
|
||||||
|
|
@ -123,35 +157,123 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
val coupleId = state.coupleId ?: return
|
val coupleId = state.coupleId ?: return
|
||||||
val partnerId = state.partnerId ?: return
|
val partnerId = state.partnerId ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
if (state.sealedRevealPhase == SealedRevealPhase.RELEASING_KEY) return@launch
|
||||||
|
if (state.sealedRevealPhase == SealedRevealPhase.WAITING_FOR_PARTNER) {
|
||||||
|
tryDecryptPartnerAnswer(coupleId, partnerId, state)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val partnerAnswer = runCatching {
|
val partnerAnswer = runCatching {
|
||||||
firestoreAnswerDataSource.getAnswerForUser(
|
firestoreAnswerDataSource.getAnswerForUser(
|
||||||
coupleId = coupleId,
|
coupleId = coupleId,
|
||||||
userId = partnerId,
|
userId = partnerId,
|
||||||
date = FirestoreAnswerDataSource.todayLocalDateString()
|
date = effectiveDate(state.answer)
|
||||||
)
|
)
|
||||||
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
partnerAnswer?.let { _uiState.update { it.copy(partnerAnswer = partnerAnswer) } }
|
partnerAnswer?.let { pa ->
|
||||||
|
val newPhase = computeSealedPhase(state.answer, pa)
|
||||||
|
_uiState.update { it.copy(partnerAnswer = pa, sealedRevealPhase = newPhase) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun revealAnswer() {
|
fun revealAnswer() {
|
||||||
|
val state = _uiState.value
|
||||||
|
if (state.sealedRevealPhase == SealedRevealPhase.BOTH_ANSWERED) {
|
||||||
|
performSealedReveal(state)
|
||||||
|
} else {
|
||||||
|
performLegacyReveal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performSealedReveal(state: AnswerRevealUiState) {
|
||||||
|
val coupleId = state.coupleId ?: return
|
||||||
|
val partnerId = state.partnerId ?: return
|
||||||
|
val userId = authRepository.currentUserId ?: return
|
||||||
|
val date = effectiveDate(state.answer)
|
||||||
|
|
||||||
|
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.RELEASING_KEY) }
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val released = runCatching {
|
||||||
|
sealedRevealManager.releaseOwnKey(
|
||||||
|
coupleId = coupleId,
|
||||||
|
date = date,
|
||||||
|
questionId = questionId,
|
||||||
|
userId = userId,
|
||||||
|
partnerId = partnerId
|
||||||
|
)
|
||||||
|
}.onFailure { crashReporter.recordException(it) }.getOrDefault(false)
|
||||||
|
|
||||||
|
if (!released) {
|
||||||
|
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.LOST_LOCAL_KEY) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
tryDecryptPartnerAnswer(coupleId, partnerId, _uiState.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun tryDecryptPartnerAnswer(
|
||||||
|
coupleId: String,
|
||||||
|
partnerId: String,
|
||||||
|
state: AnswerRevealUiState
|
||||||
|
) {
|
||||||
|
val userId = authRepository.currentUserId ?: return
|
||||||
|
val encryptedPayload = state.partnerAnswer?.encryptedPayload
|
||||||
|
if (encryptedPayload == null) {
|
||||||
|
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.WAITING_FOR_PARTNER) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val payload = runCatching {
|
||||||
|
sealedRevealManager.decryptPartnerAnswer(
|
||||||
|
coupleId = coupleId,
|
||||||
|
date = effectiveDate(state.partnerAnswer),
|
||||||
|
questionId = questionId,
|
||||||
|
partnerId = partnerId,
|
||||||
|
userId = userId,
|
||||||
|
encryptedPayload = encryptedPayload
|
||||||
|
)
|
||||||
|
}.onFailure { crashReporter.recordException(it) }.getOrNull()
|
||||||
|
|
||||||
|
if (payload == null) {
|
||||||
|
_uiState.update { it.copy(sealedRevealPhase = SealedRevealPhase.WAITING_FOR_PARTNER) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val decryptedPartnerAnswer = state.partnerAnswer?.copy(
|
||||||
|
writtenText = payload.writtenText,
|
||||||
|
selectedOptionIds = payload.selectedOptionIds,
|
||||||
|
scaleValue = payload.scaleValue,
|
||||||
|
isSealed = false
|
||||||
|
)
|
||||||
|
|
||||||
|
localAnswerRepository.markRevealed(questionId)
|
||||||
|
val ownAnswer = localAnswerRepository.getAnswer(questionId)
|
||||||
|
val category = ownAnswer?.category ?: state.question?.category ?: ""
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
answer = ownAnswer,
|
||||||
|
partnerAnswer = decryptedPartnerAnswer,
|
||||||
|
sealedRevealPhase = SealedRevealPhase.REVEALED,
|
||||||
|
followUpOptions = generateFollowUpOptions(ownAnswer, decryptedPartnerAnswer, category)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performLegacyReveal() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
localAnswerRepository.markRevealed(questionId)
|
localAnswerRepository.markRevealed(questionId)
|
||||||
val answer = localAnswerRepository.getAnswer(questionId)
|
val answer = localAnswerRepository.getAnswer(questionId)
|
||||||
val partnerAnswer = _uiState.value.partnerAnswer
|
val partnerAnswer = _uiState.value.partnerAnswer
|
||||||
val category = answer?.category ?: _uiState.value.question?.category ?: ""
|
val category = answer?.category ?: _uiState.value.question?.category ?: ""
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category))
|
||||||
followUpOptions = generateFollowUpOptions(answer, partnerAnswer, category)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Selects up to 2 optional follow-up prompts for after a reveal.
|
|
||||||
* Uses category context when available and never creates pressure.
|
|
||||||
*/
|
|
||||||
private fun generateFollowUpOptions(
|
private fun generateFollowUpOptions(
|
||||||
answer: LocalAnswer?,
|
answer: LocalAnswer?,
|
||||||
partnerAnswer: LocalAnswer?,
|
partnerAnswer: LocalAnswer?,
|
||||||
|
|
@ -190,4 +312,8 @@ class AnswerRevealViewModel @Inject constructor(
|
||||||
fun clearSnackbar() {
|
fun clearSnackbar() {
|
||||||
_uiState.update { it.copy(snackbarMessage = null) }
|
_uiState.update { it.copy(snackbarMessage = null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun effectiveDate(answer: LocalAnswer?): String =
|
||||||
|
answer?.answerDate?.takeIf { it.isNotBlank() }
|
||||||
|
?: FirestoreAnswerDataSource.todayLocalDateString()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import app.closer.crypto.CoupleEncryptionManager
|
import app.closer.crypto.CoupleEncryptionManager
|
||||||
import app.closer.crypto.EncryptionStatus
|
import app.closer.crypto.EncryptionStatus
|
||||||
|
import app.closer.crypto.SealedRevealManager
|
||||||
import app.closer.data.remote.FirestoreAnswerDataSource
|
import app.closer.data.remote.FirestoreAnswerDataSource
|
||||||
import app.closer.data.remote.FirestoreCapsuleDataSource
|
import app.closer.data.remote.FirestoreCapsuleDataSource
|
||||||
import app.closer.data.remote.FirestoreChallengeDataSource
|
import app.closer.data.remote.FirestoreChallengeDataSource
|
||||||
|
|
@ -141,7 +142,8 @@ class HomeViewModel @Inject constructor(
|
||||||
private val questionSessionRepository: QuestionSessionRepository,
|
private val questionSessionRepository: QuestionSessionRepository,
|
||||||
private val challengeDataSource: FirestoreChallengeDataSource,
|
private val challengeDataSource: FirestoreChallengeDataSource,
|
||||||
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
private val capsuleDataSource: FirestoreCapsuleDataSource,
|
||||||
private val datePlanRepository: DatePlanRepository
|
private val datePlanRepository: DatePlanRepository,
|
||||||
|
private val sealedRevealManager: SealedRevealManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(HomeUiState())
|
private val _uiState = MutableStateFlow(HomeUiState())
|
||||||
|
|
@ -178,6 +180,7 @@ class HomeViewModel @Inject constructor(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val uid = authRepository.currentUserId
|
val uid = authRepository.currentUserId
|
||||||
|
uid?.let { launch { runCatching { sealedRevealManager.ensurePublicKeyPublished(it) } } }
|
||||||
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
|
val couple = uid?.let { runCatching { coupleRepository.getCoupleForUser(it) }.getOrNull() }
|
||||||
val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId ->
|
val partnerName = couple?.userIds?.firstOrNull { it != uid }?.let { partnerId ->
|
||||||
runCatching { userRepository.getUser(partnerId)?.displayName }
|
runCatching { userRepository.getUser(partnerId)?.displayName }
|
||||||
|
|
|
||||||
|
|
@ -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\\\""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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."
|
||||||
|
]
|
||||||
|
}
|
||||||
106
firestore.rules
106
firestore.rules
|
|
@ -69,6 +69,43 @@ service cloud.firestore {
|
||||||
&& (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue));
|
&& (!('scaleValue' in data) || data.scaleValue == null || isCiphertext(data.scaleValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sealed-answer helpers (schemaVersion 3, partner-proof reveal).
|
||||||
|
|
||||||
|
function isSealedPayload(value) {
|
||||||
|
return value is string && value.matches('^sealed:v1:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKeybox(value) {
|
||||||
|
return value is string && value.matches('^keybox:v1:');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCommitmentHash(value) {
|
||||||
|
return value is string && value.matches('^sha256:');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true when the incoming data satisfies the sealed-answer create shape.
|
||||||
|
// Plaintext content fields (writtenText, selectedOptionIds, scaleValue) are
|
||||||
|
// rejected by the hasOnly check below — they must never appear in a sealed doc.
|
||||||
|
function isSealedAnswerCreate(data) {
|
||||||
|
return data.keys().hasOnly([
|
||||||
|
'userId', 'questionId', 'answerType', 'encryptedPayload',
|
||||||
|
'commitmentHash', 'schemaVersion', 'answerKeyReleased',
|
||||||
|
'createdAt', 'updatedAt', 'isRevealed'
|
||||||
|
])
|
||||||
|
&& isSealedPayload(data.encryptedPayload)
|
||||||
|
&& isCommitmentHash(data.commitmentHash)
|
||||||
|
&& data.schemaVersion == 3
|
||||||
|
&& data.answerKeyReleased == false
|
||||||
|
&& data.isRevealed == false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the reveal metadata fields may change after a sealed answer is created.
|
||||||
|
function isSealedAnswerUpdate() {
|
||||||
|
return resource.data.schemaVersion == 3
|
||||||
|
&& request.resource.data.diff(resource.data).affectedKeys()
|
||||||
|
.hasOnly(['isRevealed', 'answerKeyReleased', 'updatedAt']);
|
||||||
|
}
|
||||||
|
|
||||||
function isStartingEncryptionMigration() {
|
function isStartingEncryptionMigration() {
|
||||||
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
|
return (resource.data.encryptionVersion == null || resource.data.encryptionVersion == 0)
|
||||||
&& request.resource.data.encryptionVersion == 1
|
&& request.resource.data.encryptionVersion == 1
|
||||||
|
|
@ -149,6 +186,18 @@ service cloud.firestore {
|
||||||
match /fcmTokens/{tokenId} {
|
match /fcmTokens/{tokenId} {
|
||||||
allow read, write: if isOwner(uid);
|
allow read, write: if isOwner(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user ECIES public keys for sealed-answer key release.
|
||||||
|
// The owner writes their own public key; couple members may read it to wrap
|
||||||
|
// release keys. Private key material never appears here.
|
||||||
|
match /devices/{deviceId} {
|
||||||
|
allow read: if isSignedIn();
|
||||||
|
allow create, update: if isOwner(uid)
|
||||||
|
&& request.resource.data.publicKey is string
|
||||||
|
&& request.resource.data.publicKey.matches('^pub:v1:')
|
||||||
|
&& request.resource.data.keys().hasOnly(['deviceId', 'publicKey', 'platform', 'updatedAt']);
|
||||||
|
allow delete: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Date ideas (read-only catalog) ─────────────────────────────────────────
|
// ── Date ideas (read-only catalog) ─────────────────────────────────────────
|
||||||
|
|
@ -427,23 +476,68 @@ service cloud.firestore {
|
||||||
// Daily question answers: each user writes their own; both members read.
|
// Daily question answers: each user writes their own; both members read.
|
||||||
match /daily_question/{date}/answers/{userId} {
|
match /daily_question/{date}/answers/{userId} {
|
||||||
allow read: if isCouplesMember(coupleId);
|
allow read: if isCouplesMember(coupleId);
|
||||||
|
|
||||||
allow create: if isCouplesMember(coupleId)
|
allow create: if isCouplesMember(coupleId)
|
||||||
&& request.auth.uid == userId
|
&& request.auth.uid == userId
|
||||||
&& request.resource.data.keys().hasOnly(['userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'createdAt', 'updatedAt'])
|
|
||||||
&& request.resource.data.userId == request.auth.uid
|
&& request.resource.data.userId == request.auth.uid
|
||||||
&& request.resource.data.questionId is string
|
&& request.resource.data.questionId is string
|
||||||
&& request.resource.data.answerType is string
|
&& request.resource.data.answerType is string
|
||||||
&& coupleEncryptionEnabled(coupleId)
|
&& (
|
||||||
&& isEncryptedAnswerPayload(request.resource.data);
|
// schemaVersion 3: partner-proof sealed answer.
|
||||||
|
isSealedAnswerCreate(request.resource.data)
|
||||||
|
||
|
||||||
|
// schemaVersion 2: couple-key encrypted answer (legacy path).
|
||||||
|
(coupleEncryptionEnabled(coupleId)
|
||||||
|
&& request.resource.data.schemaVersion == 2
|
||||||
|
&& request.resource.data.keys().hasOnly([
|
||||||
|
'userId', 'questionId', 'answerType', 'writtenText',
|
||||||
|
'selectedOptionIds', 'scaleValue', 'schemaVersion',
|
||||||
|
'createdAt', 'updatedAt', 'isRevealed'
|
||||||
|
])
|
||||||
|
&& isEncryptedAnswerPayload(request.resource.data))
|
||||||
|
);
|
||||||
|
|
||||||
allow update: if isCouplesMember(coupleId)
|
allow update: if isCouplesMember(coupleId)
|
||||||
&& request.auth.uid == userId
|
&& request.auth.uid == userId
|
||||||
&& request.resource.data.userId == resource.data.userId
|
&& request.resource.data.userId == resource.data.userId
|
||||||
&& request.resource.data.questionId == resource.data.questionId
|
&& request.resource.data.questionId == resource.data.questionId
|
||||||
&& request.resource.data.answerType == resource.data.answerType
|
&& request.resource.data.answerType == resource.data.answerType
|
||||||
&& request.resource.data.keys().hasOnly(['userId', 'questionId', 'answerType', 'writtenText', 'selectedOptionIds', 'scaleValue', 'createdAt', 'updatedAt'])
|
&& (
|
||||||
&& coupleEncryptionEnabled(coupleId)
|
// Sealed answers: only reveal metadata may change; payload is immutable.
|
||||||
&& isEncryptedAnswerPayload(request.resource.data);
|
isSealedAnswerUpdate()
|
||||||
|
||
|
||||||
|
// enc:v1: answers: same field set, content may be updated.
|
||||||
|
(coupleEncryptionEnabled(coupleId)
|
||||||
|
&& resource.data.schemaVersion != 3
|
||||||
|
&& request.resource.data.keys().hasOnly([
|
||||||
|
'userId', 'questionId', 'answerType', 'writtenText',
|
||||||
|
'selectedOptionIds', 'scaleValue', 'schemaVersion',
|
||||||
|
'createdAt', 'updatedAt', 'isRevealed'
|
||||||
|
])
|
||||||
|
&& isEncryptedAnswerPayload(request.resource.data))
|
||||||
|
);
|
||||||
|
|
||||||
allow delete: if false;
|
allow delete: if false;
|
||||||
|
|
||||||
|
// Release keys: the sender releases their one-time answer key to the recipient
|
||||||
|
// after both partners have submitted.
|
||||||
|
match /releaseKeys/{recipientId} {
|
||||||
|
// Only the recipient can read their own release key.
|
||||||
|
allow read: if request.auth.uid == recipientId && isCouplesMember(coupleId);
|
||||||
|
|
||||||
|
// Create-only: written by the answer owner (sender) after both answers exist.
|
||||||
|
allow create: if isCouplesMember(coupleId)
|
||||||
|
&& request.auth.uid == userId
|
||||||
|
&& recipientId != userId
|
||||||
|
&& recipientId in get(/databases/$(database)/documents/couples/$(coupleId)).data.userIds
|
||||||
|
&& exists(/databases/$(database)/documents/couples/$(coupleId)/daily_question/$(date)/answers/$(recipientId))
|
||||||
|
&& isKeybox(request.resource.data.encryptedAnswerKey)
|
||||||
|
&& request.resource.data.recipientUserId == recipientId
|
||||||
|
&& request.resource.data.keys().hasOnly(['recipientUserId', 'encryptedAnswerKey', 'releasedAt']);
|
||||||
|
|
||||||
|
allow update: if false;
|
||||||
|
allow delete: if false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
match /{gameCollection}/{sessionId} {
|
match /{gameCollection}/{sessionId} {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue