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