feat: E2EE — Tink AEAD, Argon2id KDF, recovery phrase, encrypted Firestore fields (batch v0.2.6)

- Add crypto module: CoupleKeyStore (EncryptedSharedPreferences), RecoveryKeyManager (Argon2id + AES-256-GCM key wrap), FieldEncryptor (AEAD per-field), CoupleEncryptionManager (orchestration)
- Add Tink + Bouncy Castle dependencies to build.gradle.kts, register AeadConfig in CloserApp
- Encrypt answer fields (writtenText, selectedOptionIds, scaleValue) on write, decrypt on read
- Encrypt DesireSync, HowWell, WheelAnswer, QuestionThread fields via CoupleEncryptionManager
- Generate recovery phrase during invite creation, display in CreateInviteScreen
- Add recovery phrase input to InviteConfirmScreen for encrypted invites
- Add RecoveryScreen + RecoveryViewModel for post-pairing key recovery
- Update Couple model with encryptionVersion, wrappedCoupleKey, kdfSalt, kdfParams
- Update Firestore rules: allow couple doc creation by members, fcmTokens path, encryptionVersion monotonic check, invite doc extended fields
This commit is contained in:
null 2026-06-19 19:52:35 -05:00
parent 5caae523e7
commit 30fddcc2df
30 changed files with 1138 additions and 95 deletions

View File

@ -130,6 +130,10 @@ dependencies {
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
// E2EE: Google Tink (AEAD) + Bouncy Castle (Argon2id KDF)
implementation("com.google.crypto.tink:tink-android:1.13.0")
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
// Debug
debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest")

View File

@ -4,6 +4,7 @@ import android.app.Application
import app.closer.core.firebase.FirebaseInitializer
import app.closer.data.repository.ActivityProvider
import app.closer.domain.security.DeviceIntegrityChecker
import com.google.crypto.tink.aead.AeadConfig
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -21,6 +22,7 @@ class CloserApp : Application() {
override fun onCreate() {
super.onCreate()
AeadConfig.register()
ActivityProvider.register(this)
firebaseInitializer.initialize()
appScope.launch { deviceIntegrityChecker.runCheck() }

View File

@ -43,6 +43,7 @@ import app.closer.ui.pairing.CreateInviteScreen
import app.closer.ui.pairing.EmailInviteScreen
import app.closer.ui.pairing.InviteConfirmScreen
import app.closer.ui.pairing.PairPromptScreen
import app.closer.ui.pairing.RecoveryScreen
import app.closer.ui.dates.DateMatchScreen
import app.closer.ui.dates.DateMatchesScreen
import app.closer.ui.dates.DateBuilderScreen
@ -276,6 +277,15 @@ fun AppNavigation(
onBack = navigateBackOrHome
)
}
composable(route = AppRoute.RECOVERY) {
RecoveryScreen(
onRecovered = {
navController.navigate(AppRoute.HOME) {
popUpTo(AppRoute.RECOVERY) { inclusive = true }
}
}
)
}
// Wheel / Category Selection
composable(route = AppRoute.CATEGORY_PICKER) {

View File

@ -49,6 +49,7 @@ object AppRoute {
const val CONNECTION_CHALLENGES = "connection_challenges"
const val MEMORY_LANE = "memory_lane"
const val WAITING_FOR_PARTNER = "waiting_for_partner"
const val RECOVERY = "recovery"
// Question thread: coupleId and questionId are required; prevId and nextId are optional.
const val QUESTION_THREAD =

View File

@ -0,0 +1,101 @@
package app.closer.crypto
import app.closer.domain.model.Couple
import com.google.crypto.tink.Aead
import com.google.crypto.tink.KeysetHandle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
enum class EncryptionStatus {
/** Local keyset present — ready to encrypt/decrypt. */
UNLOCKED,
/** Found keyset in the invite slot; moved to coupleId slot automatically. */
RECONCILED_FROM_INVITE,
/** encryptionVersion == 1 but no local keyset — prompt for recovery phrase. */
NEEDS_RECOVERY,
/** encryptionVersion == 0 (old couple) — operates in plaintext passthrough. */
PLAINTEXT_COUPLE
}
data class SetupResult(
val handle: KeysetHandle,
val wrapped: RecoveryKeyManager.WrappedKey,
val recoveryPhrase: String
)
/**
* High-level E2EE orchestration injected into repositories and ViewModels.
* All KDF operations run on [Dispatchers.Default] to keep the main thread free.
*/
@Singleton
class CoupleEncryptionManager @Inject constructor(
private val keyStore: CoupleKeyStore,
private val keyManager: RecoveryKeyManager
) {
/**
* Called by the inviter when creating an invite.
* Generates a new couple keyset + recovery phrase, wraps the keyset,
* and stores it locally under the invite slot (coupleId unknown yet).
*/
suspend fun setupForNewCouple(inviteCode: String): SetupResult = withContext(Dispatchers.Default) {
val phrase = keyManager.generateRecoveryPhrase()
val handle = keyManager.newCoupleKeyset()
val wrapped = keyManager.wrap(handle, phrase)
keyStore.storeInviteKeyset(inviteCode, handle)
SetupResult(handle, wrapped, phrase)
}
/**
* Called by the acceptor during pairing, and on any device during recovery.
* Derives the wrap key from [phrase], unwraps the keyset, and stores it
* locally under [coupleId].
* Throws [com.google.crypto.tink.subtle.Validators]-wrapped exception if phrase is wrong.
*/
suspend fun unwrapAndStore(
coupleId: String,
wrapped: RecoveryKeyManager.WrappedKey,
phrase: String
): Result<Unit> = withContext(Dispatchers.Default) {
runCatching {
val handle = keyManager.unwrap(wrapped, phrase)
keyStore.storeKeyset(coupleId, handle)
}
}
/**
* Called on app launch / Home load after the couple doc is resolved.
* Handles inviter reconciliation (flow B) transparently.
*/
fun checkStatus(couple: Couple): EncryptionStatus {
if (couple.encryptionVersion == 0) return EncryptionStatus.PLAINTEXT_COUPLE
if (keyStore.hasKeyset(couple.id)) return EncryptionStatus.UNLOCKED
if (keyStore.reconcileInviteKeyset(couple.inviteCode, couple.id)) {
return EncryptionStatus.RECONCILED_FROM_INVITE
}
return EncryptionStatus.NEEDS_RECOVERY
}
fun aeadFor(coupleId: String): Aead? = keyStore.aeadFor(coupleId)
fun isUnlocked(coupleId: String): Boolean = keyStore.hasKeyset(coupleId)
/**
* Re-wraps the locally-held keyset with a new phrase and returns the new WrappedKey
* so the caller can persist it to Firestore. The old phrase is NOT required.
* Returns failure if no local keyset exists for [coupleId].
*/
suspend fun rewrapWithNewPhrase(
coupleId: String,
newPhrase: String
): Result<RecoveryKeyManager.WrappedKey> = withContext(Dispatchers.Default) {
runCatching {
val handle = keyStore.loadKeyset(coupleId)
?: error("No local keyset for $coupleId — cannot change phrase without recovery first")
keyManager.wrap(handle, newPhrase)
}
}
fun deleteKeyset(coupleId: String) = keyStore.deleteKeyset(coupleId)
}

View File

@ -0,0 +1,98 @@
package app.closer.crypto
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
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 dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayOutputStream
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* Persists Tink keyset handles in EncryptedSharedPreferences (Keystore-backed).
* Keys are namespaced by coupleId ("keyset_{coupleId}") or invite slot
* ("keyset_invite_{inviteCode}") for the inviter reconciliation path.
*/
@Singleton
class CoupleKeyStore @Inject constructor(
@ApplicationContext context: Context
) {
private val prefs: SharedPreferences = run {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
EncryptedSharedPreferences.create(
masterKeyAlias,
"couple_crypto_secure",
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
private val aeadCache = ConcurrentHashMap<String, Aead>()
fun hasKeyset(coupleId: String): Boolean =
prefs.contains(prefKey(coupleId))
fun hasInviteKeyset(inviteCode: String): Boolean =
prefs.contains(invitePrefKey(inviteCode))
fun storeKeyset(coupleId: String, handle: KeysetHandle) {
val json = serialize(handle)
prefs.edit().putString(prefKey(coupleId), json).apply()
aeadCache[coupleId] = handle.getPrimitive(Aead::class.java)
}
fun storeInviteKeyset(inviteCode: String, handle: KeysetHandle) {
val json = serialize(handle)
prefs.edit().putString(invitePrefKey(inviteCode), json).apply()
}
fun loadKeyset(coupleId: String): KeysetHandle? =
load(prefKey(coupleId))
fun loadInviteKeyset(inviteCode: String): KeysetHandle? =
load(invitePrefKey(inviteCode))
/** Moves the invite-slot keyset to the coupleId slot and removes the invite slot. */
fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean {
val handle = loadInviteKeyset(inviteCode) ?: return false
storeKeyset(coupleId, handle)
prefs.edit().remove(invitePrefKey(inviteCode)).apply()
return true
}
fun deleteKeyset(coupleId: String) {
prefs.edit().remove(prefKey(coupleId)).apply()
aeadCache.remove(coupleId)
}
fun aeadFor(coupleId: String): Aead? {
aeadCache[coupleId]?.let { return it }
val handle = loadKeyset(coupleId) ?: return null
val aead = handle.getPrimitive(Aead::class.java)
aeadCache[coupleId] = aead
return aead
}
private fun prefKey(coupleId: String) = "keyset_$coupleId"
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
private fun serialize(handle: KeysetHandle): String {
val baos = ByteArrayOutputStream()
CleartextKeysetHandle.write(handle, JsonKeysetWriter.withOutputStream(baos))
return baos.toString(Charsets.UTF_8.name())
}
private fun load(key: String): KeysetHandle? =
runCatching {
val json = prefs.getString(key, null) ?: return null
CleartextKeysetHandle.read(JsonKeysetReader.withString(json))
}.getOrNull()
}

View File

@ -0,0 +1,51 @@
package app.closer.crypto
import android.util.Base64
import com.google.crypto.tink.Aead
import javax.inject.Inject
import javax.inject.Singleton
/**
* Stateless helper that encrypts/decrypts individual Firestore field values.
*
* Wire format: "enc:v1:{base64(tinkCiphertext)}"
* Plaintext values (no prefix) pass through unchanged so legacy data works.
*
* AAD = coupleId bytes binds ciphertext to the couple and prevents
* copy-paste of one couple's ciphertext into another couple's document.
*/
@Singleton
class FieldEncryptor @Inject constructor() {
fun encrypt(plaintext: String, aead: Aead, coupleId: String): String {
val cipher = aead.encrypt(
plaintext.toByteArray(Charsets.UTF_8),
coupleId.toByteArray(Charsets.UTF_8)
)
return PREFIX + Base64.encodeToString(cipher, Base64.NO_WRAP)
}
fun encryptNullable(value: String?, aead: Aead, coupleId: String): String? =
value?.let { encrypt(it, aead, coupleId) }
/**
* Decrypts if the value has the enc:v1: prefix; returns the original string otherwise.
* Returns null when [value] is null or [aead] is null and the value is encrypted.
*/
fun decrypt(value: String?, aead: Aead?, coupleId: String): String? {
if (value == null) return null
if (!value.startsWith(PREFIX)) return value
if (aead == null) return null
return runCatching {
val cipher = Base64.decode(value.removePrefix(PREFIX), Base64.NO_WRAP)
aead.decrypt(cipher, coupleId.toByteArray(Charsets.UTF_8))
.toString(Charsets.UTF_8)
}.getOrNull()
}
fun isEncrypted(value: String?): Boolean = value?.startsWith(PREFIX) == true
companion object {
const val PREFIX = "enc:v1:"
}
}

View File

@ -0,0 +1,140 @@
package app.closer.crypto
import android.util.Base64
import com.google.crypto.tink.KeysetHandle
import com.google.crypto.tink.aead.AesGcmKeyManager
import com.google.crypto.tink.subtle.AesGcmJce
import org.bouncycastle.crypto.generators.Argon2BytesGenerator
import org.bouncycastle.crypto.params.Argon2Parameters
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
/**
* Pure crypto helper no Firestore, no Android dependencies.
* Handles: keyset generation, Argon2id KDF, couple-key wrap/unwrap, phrase generation.
*
* Argon2id params (m=46 MiB, t=3, p=1): ~2-3 s on a mid-range phone.
* Run on Dispatchers.Default to avoid blocking the main thread.
*/
@Singleton
class RecoveryKeyManager @Inject constructor() {
data class WrappedKey(
val cipherB64: String,
val saltB64: String,
val params: String
)
fun generateRecoveryPhrase(): String {
val random = SecureRandom()
val indices = (0 until PHRASE_WORD_COUNT).map { random.nextInt(WORDLIST.size) }
return indices.joinToString(" ") { WORDLIST[it] }
}
fun newCoupleKeyset(): KeysetHandle =
KeysetHandle.generateNew(AesGcmKeyManager.aes256GcmTemplate())
/**
* Wraps [keyset] with Argon2id(passphrase, salt) using AES-256-GCM.
* AAD is the fixed constant [WRAP_AAD] so the blob is portable across
* invite couple transition without re-wrapping.
*/
fun wrap(keyset: KeysetHandle, phrase: String): WrappedKey {
val salt = ByteArray(SALT_BYTES).also { SecureRandom().nextBytes(it) }
val wrapKey = deriveKey(phrase, salt)
val keysetBytes = serializeKeyset(keyset)
val cipherBytes = AesGcmJce(wrapKey).encrypt(keysetBytes, WRAP_AAD)
return WrappedKey(
cipherB64 = Base64.encodeToString(cipherBytes, Base64.NO_WRAP),
saltB64 = Base64.encodeToString(salt, Base64.NO_WRAP),
params = PARAMS_TAG
)
}
/**
* Unwraps a [WrappedKey] using [phrase]. Throws [javax.crypto.AEADBadTagException]
* (wrapped as GeneralSecurityException) if the phrase is wrong.
*/
fun unwrap(wrapped: WrappedKey, phrase: String): KeysetHandle {
val salt = Base64.decode(wrapped.saltB64, Base64.NO_WRAP)
val cipher = Base64.decode(wrapped.cipherB64, Base64.NO_WRAP)
val wrapKey = deriveKey(phrase, salt)
val keysetBytes = AesGcmJce(wrapKey).decrypt(cipher, WRAP_AAD)
return deserializeKeyset(keysetBytes)
}
/** Serialize a KeysetHandle to bytes (cleartext JSON — stored inside EncryptedSharedPreferences). */
fun serializeKeyset(handle: KeysetHandle): ByteArray {
val baos = java.io.ByteArrayOutputStream()
com.google.crypto.tink.CleartextKeysetHandle.write(
handle, com.google.crypto.tink.JsonKeysetWriter.withOutputStream(baos)
)
return baos.toByteArray()
}
fun deserializeKeyset(bytes: ByteArray): KeysetHandle =
com.google.crypto.tink.CleartextKeysetHandle.read(
com.google.crypto.tink.JsonKeysetReader.withBytes(bytes)
)
private fun deriveKey(phrase: String, salt: ByteArray): ByteArray {
val params = Argon2Parameters.Builder(Argon2Parameters.ARGON2_id)
.withSalt(salt)
.withParallelism(ARGON2_PARALLELISM)
.withMemoryAsKB(ARGON2_MEMORY_KB)
.withIterations(ARGON2_ITERATIONS)
.build()
val gen = Argon2BytesGenerator()
gen.init(params)
val out = ByteArray(KEY_BYTES)
gen.generateBytes(phrase.toCharArray(), out, 0, KEY_BYTES)
return out
}
companion object {
private const val PHRASE_WORD_COUNT = 6
private const val SALT_BYTES = 16
private const val KEY_BYTES = 32
private const val ARGON2_MEMORY_KB = 46 * 1024
private const val ARGON2_ITERATIONS = 3
private const val ARGON2_PARALLELISM = 1
private const val PARAMS_TAG = "argon2id;v=19;m=47104;t=3;p=1"
private val WRAP_AAD = "closer_couple_key".toByteArray(Charsets.UTF_8)
// 256-word list → 6 words → 48 bits raw entropy; Argon2id makes brute-force infeasible.
val WORDLIST = arrayOf(
"able","acid","acre","aged","aide","also","army","atom",
"baby","back","bake","ball","bank","barn","base","bath",
"bead","beam","bear","beat","belt","best","bill","bird",
"bite","blue","boat","bold","bone","book","boot","bore",
"cage","cake","call","calm","camp","cape","card","care",
"cart","cave","cell","cent","coal","coat","code","coin",
"cold","come","cone","cord","core","corn","cost","crew",
"dame","damp","dare","dark","dart","date","dead","deal",
"dean","deer","dent","dice","disk","dish","dock","done",
"dose","dove","down","draw","drip","drop","drum","dusk",
"each","earn","east","edge","epic","even","exam","exit",
"fact","fail","fair","fall","fame","farm","fast","fate",
"feel","fill","film","find","fire","fish","five","flat",
"flow","foam","food","foot","form","free","fuel","full",
"gain","game","gate","gear","give","glad","glow","goal",
"gold","good","gray","grow","gulf","gust","half","hall",
"halt","hand","hard","harm","head","heat","help","high",
"hill","hint","hold","hole","home","hook","hope","hour",
"huge","hull","hunt","hurt","idea","idle","inch","iris",
"jade","jail","jest","join","jury","just","keen","kept",
"kind","king","knee","knew","know","land","lane","last",
"late","lead","leaf","lean","left","less","life","like",
"line","lion","list","live","load","lock","long","look",
"loop","lord","lost","loud","love","made","mail","main",
"make","mark","maze","mean","meet","mild","mind","mine",
"miss","mode","more","most","move","much","must","name",
"near","need","nest","news","next","nice","nine","node",
"noon","norm","note","once","only","open","over","pack",
"page","pain","pair","pale","park","part","past","path",
"peak","pick","pine","plan","play","plus","poor","port",
"post","pull","pure","race","rank","rate","read","real"
)
}
}

View File

@ -1,8 +1,11 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.LocalAnswer
import com.google.firebase.firestore.FirebaseFirestore
import kotlinx.coroutines.suspendCancellableCoroutine
import org.json.JSONArray
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
@ -21,7 +24,11 @@ import kotlin.coroutines.resumeWithException
* both partners may read either answer. Firestore rules enforce this.
*/
@Singleton
class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFirestore) {
class FirestoreAnswerDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun dailyQuestionRef(coupleId: String, date: String) =
db.collection(FirestoreCollections.COUPLES)
@ -43,13 +50,18 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
answer: LocalAnswer
): Unit = suspendCancellableCoroutine { cont ->
val date = todayUtcString()
val aead = encryptionManager.aeadFor(coupleId)
val data = mapOf(
"userId" to userId,
"questionId" to questionId,
"answerType" to answer.answerType,
"writtenText" to answer.writtenText,
"selectedOptionIds" to answer.selectedOptionIds,
"scaleValue" to answer.scaleValue,
"writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
"selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
else answer.selectedOptionIds,
"scaleValue" to if (aead != null && answer.scaleValue != null)
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
else answer.scaleValue,
"createdAt" to answer.createdAt,
"updatedAt" to answer.updatedAt,
"isRevealed" to answer.isRevealed
@ -76,7 +88,8 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
cont.resume(null)
return@addOnSuccessListener
}
cont.resume(snap.toLocalAnswer())
val aead = encryptionManager.aeadFor(coupleId)
cont.resume(snap.toLocalAnswer(aead, coupleId))
}
.addOnFailureListener { cont.resumeWithException(it) }
}
@ -120,17 +133,39 @@ class FirestoreAnswerDataSource @Inject constructor(private val db: FirebaseFire
}
@Suppress("UNCHECKED_CAST")
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(): LocalAnswer {
val ids = get("selectedOptionIds") as? List<String> ?: emptyList()
private fun com.google.firebase.firestore.DocumentSnapshot.toLocalAnswer(
aead: com.google.crypto.tink.Aead?,
coupleId: String
): LocalAnswer {
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 {
val arr = org.json.JSONArray(decrypted)
(0 until arr.length()).map { arr.getString(it) }
}.getOrDefault(emptyList()) else emptyList()
} else rawIds
val rawScale = get("scaleValue")
val scaleValue: Int? = when {
rawScale == null -> null
rawScale is String && fieldEncryptor.isEncrypted(rawScale) ->
fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull()
rawScale is Long -> rawScale.toInt()
rawScale is Int -> rawScale
else -> null
}
return LocalAnswer(
questionId = getString("questionId") ?: "",
questionText = "",
category = "",
answerType = getString("answerType") ?: "written",
writtenText = getString("writtenText"),
selectedOptionIds = ids,
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
selectedOptionIds = selectedOptionIds,
selectedOptionTexts = emptyList(),
scaleValue = if (getLong("scaleValue") == null && get("scaleValue") == null) null else (getLong("scaleValue")?.toInt() ?: get("scaleValue") as? Int),
scaleValue = scaleValue,
createdAt = getLong("createdAt") ?: System.currentTimeMillis(),
updatedAt = getLong("updatedAt") ?: System.currentTimeMillis(),
isRevealed = getBoolean("isRevealed") ?: false

View File

@ -1,11 +1,11 @@
package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Couple
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.resume
@ -16,10 +16,16 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
private fun coupleRef(coupleId: String) = db.collection(FirestoreCollections.COUPLES).document(coupleId)
private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid)
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): String {
val coupleId = UUID.randomUUID().toString()
/** [coupleId] is pre-generated by the repository so the keyset can be stored locally first. */
suspend fun createCouple(
coupleId: String,
inviterUserId: String,
acceptorUserId: String,
inviteCode: String,
wrappedKey: RecoveryKeyManager.WrappedKey?
): String {
val now = System.currentTimeMillis()
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now)
createCoupleDoc(coupleId, inviterUserId, acceptorUserId, inviteCode, now, wrappedKey)
updateUserCoupleId(inviterUserId, coupleId)
updateUserCoupleId(acceptorUserId, coupleId)
return coupleId
@ -30,21 +36,41 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
inviterUserId: String,
acceptorUserId: String,
inviteCode: String,
now: Long
now: Long,
wrappedKey: RecoveryKeyManager.WrappedKey?
): Unit = suspendCancellableCoroutine { cont ->
coupleRef(coupleId).set(
mapOf(
"id" to coupleId,
"userIds" to listOf(inviterUserId, acceptorUserId),
"inviteCode" to inviteCode,
"createdAt" to now,
"streakCount" to 0
)
val data = mutableMapOf<String, Any>(
"id" to coupleId,
"userIds" to listOf(inviterUserId, acceptorUserId),
"inviteCode" to inviteCode,
"createdAt" to now,
"streakCount" to 0
)
if (wrappedKey != null) {
data["encryptionVersion"] = 1
data["wrappedCoupleKey"] = wrappedKey.cipherB64
data["kdfSalt"] = wrappedKey.saltB64
data["kdfParams"] = wrappedKey.params
}
coupleRef(coupleId).set(data)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun updateWrappedKey(coupleId: String, wrappedKey: RecoveryKeyManager.WrappedKey): Unit =
suspendCancellableCoroutine { cont ->
coupleRef(coupleId).set(
mapOf(
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params
),
SetOptions.merge()
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
private suspend fun updateUserCoupleId(uid: String, coupleId: String): Unit =
suspendCancellableCoroutine { cont ->
userRef(uid).set(
@ -122,7 +148,11 @@ class FirestoreCoupleDataSource @Inject constructor(private val db: FirebaseFire
currentQuestionId = getString("currentQuestionId"),
streakCount = (getLong("streakCount") ?: 0L).toInt(),
lastAnsweredAt = getLong("lastAnsweredAt"),
activePackId = getString("activePackId")
activePackId = getString("activePackId"),
encryptionVersion = (getLong("encryptionVersion") ?: 0L).toInt(),
wrappedCoupleKey = getString("wrappedCoupleKey"),
kdfSalt = getString("kdfSalt"),
kdfParams = getString("kdfParams")
)
companion object {

View File

@ -1,11 +1,14 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import javax.inject.Inject
import javax.inject.Singleton
@ -23,7 +26,9 @@ data class DesireSyncAnswers(
*/
@Singleton
class FirestoreDesireSyncDataSource @Inject constructor(
private val db: FirebaseFirestore
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES)
@ -38,8 +43,12 @@ class FirestoreDesireSyncDataSource @Inject constructor(
userId: String,
optionIds: List<String>
) {
val aead = encryptionManager.aeadFor(coupleId)
val value = if (aead != null)
listOf(fieldEncryptor.encrypt(JSONArray(optionIds).toString(), aead, coupleId))
else optionIds
doc(coupleId, sessionId)
.set(mapOf("answers" to mapOf(userId to optionIds)), SetOptions.merge())
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
.await()
}
@ -47,7 +56,8 @@ class FirestoreDesireSyncDataSource @Inject constructor(
suspend fun getAnswers(coupleId: String, sessionId: String): DesireSyncAnswers? =
runCatching {
val snap = doc(coupleId, sessionId).get().await()
DesireSyncAnswers(parseAnswers(snap.get("answers")))
val aead = encryptionManager.aeadFor(coupleId)
DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId))
}.getOrNull()
/** Live view of both partners' picks; emits whenever either side submits. */
@ -55,16 +65,30 @@ class FirestoreDesireSyncDataSource @Inject constructor(
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"))))
val aead = encryptionManager.aeadFor(coupleId)
trySend(DesireSyncAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
}
awaitClose { reg.remove() }
}
private fun parseAnswers(raw: Any?): Map<String, List<String>> {
private fun parseAnswers(
raw: Any?,
aead: com.google.crypto.tink.Aead?,
coupleId: String
): Map<String, List<String>> {
@Suppress("UNCHECKED_CAST")
val map = raw as? Map<String, *> ?: return emptyMap()
return map.mapNotNull { (uid, value) ->
(value as? List<*>)?.filterIsInstance<String>()?.let { uid to it }
val list = (value as? List<*>)?.filterIsInstance<String>() ?: return@mapNotNull null
// Encrypted as a single blob; plaintext as a real list
val decrypted = if (list.size == 1 && fieldEncryptor.isEncrypted(list[0])) {
val json = fieldEncryptor.decrypt(list[0], aead, coupleId) ?: return@mapNotNull null
runCatching {
val arr = JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
}.getOrNull() ?: return@mapNotNull null
} else list
uid to decrypted
}.toMap()
}
}

View File

@ -1,11 +1,15 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
import org.json.JSONArray
import org.json.JSONObject
import javax.inject.Inject
import javax.inject.Singleton
@ -27,7 +31,9 @@ data class HowWellAnswers(
*/
@Singleton
class FirestoreHowWellDataSource @Inject constructor(
private val db: FirebaseFirestore
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES)
@ -42,16 +48,28 @@ class FirestoreHowWellDataSource @Inject constructor(
userId: String,
answers: List<HowWellRawAnswer>
) {
val payload = answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) }
val aead = encryptionManager.aeadFor(coupleId)
val value: Any = if (aead != null) {
val json = JSONArray(answers.map {
JSONObject().apply {
put("optionId", it.optionId ?: JSONObject.NULL)
put("scale", it.scale ?: JSONObject.NULL)
}
}.toString())
listOf(fieldEncryptor.encrypt(json.toString(), aead, coupleId))
} else {
answers.map { mapOf("optionId" to it.optionId, "scale" to it.scale) }
}
doc(coupleId, sessionId)
.set(mapOf("answers" to mapOf(userId to payload)), SetOptions.merge())
.set(mapOf("answers" to mapOf(userId to value)), SetOptions.merge())
.await()
}
/** One-shot read — used to detect whether this user has already answered. */
suspend fun getAnswers(coupleId: String, sessionId: String): HowWellAnswers? =
runCatching {
HowWellAnswers(parseAnswers(doc(coupleId, sessionId).get().await().get("answers")))
val aead = encryptionManager.aeadFor(coupleId)
HowWellAnswers(parseAnswers(doc(coupleId, sessionId).get().await().get("answers"), aead, coupleId))
}.getOrNull()
/** Live view of both partners' answers; emits whenever either side submits. */
@ -59,16 +77,36 @@ class FirestoreHowWellDataSource @Inject constructor(
callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(HowWellAnswers(parseAnswers(snap.get("answers"))))
val aead = encryptionManager.aeadFor(coupleId)
trySend(HowWellAnswers(parseAnswers(snap.get("answers"), aead, coupleId)))
}
awaitClose { reg.remove() }
}
private fun parseAnswers(raw: Any?): Map<String, List<HowWellRawAnswer>> {
private fun parseAnswers(
raw: Any?,
aead: com.google.crypto.tink.Aead?,
coupleId: String
): Map<String, List<HowWellRawAnswer>> {
@Suppress("UNCHECKED_CAST")
val map = raw as? Map<String, *> ?: return emptyMap()
return map.mapNotNull { (uid, value) ->
(value as? List<*>)?.let { list ->
val list = (value as? List<*>) ?: return@mapNotNull null
// Encrypted: single-element list with JSON blob
if (list.size == 1 && list[0] is String && fieldEncryptor.isEncrypted(list[0] as String)) {
val json = fieldEncryptor.decrypt(list[0] as String, aead, coupleId) ?: return@mapNotNull null
val answers = runCatching {
val arr = JSONArray(json)
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
HowWellRawAnswer(
optionId = if (obj.isNull("optionId")) null else obj.getString("optionId"),
scale = if (obj.isNull("scale")) null else obj.getInt("scale")
)
}
}.getOrNull() ?: return@mapNotNull null
uid to answers
} else {
uid to list.mapNotNull { item ->
(item as? Map<*, *>)?.let {
HowWellRawAnswer(

View File

@ -1,5 +1,6 @@
package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Invite
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
@ -18,21 +19,27 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
.joinToString("")
suspend fun createInvite(code: String, inviterUserId: String): Unit =
suspendCancellableCoroutine { cont ->
val now = System.currentTimeMillis()
inviteRef(code).set(
mapOf(
"code" to code,
"inviterUserId" to inviterUserId,
"status" to "pending",
"createdAt" to now,
"expiresAt" to now + 24 * 60 * 60 * 1000L
)
suspend fun createInvite(
code: String,
inviterUserId: String,
wrappedKey: RecoveryKeyManager.WrappedKey
): Unit = suspendCancellableCoroutine { cont ->
val now = System.currentTimeMillis()
inviteRef(code).set(
mapOf(
"code" to code,
"inviterUserId" to inviterUserId,
"status" to "pending",
"createdAt" to now,
"expiresAt" to now + 24 * 60 * 60 * 1000L,
"wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
)
.addOnSuccessListener { cont.resume(Unit) }
.addOnFailureListener { cont.resumeWithException(it) }
}
suspend fun getInviteByCode(code: String): Invite? =
suspendCancellableCoroutine { cont ->
@ -50,7 +57,10 @@ class FirestoreInviteDataSource @Inject constructor(private val db: FirebaseFire
createdAt = snap.getLong("createdAt") ?: 0L,
expiresAt = snap.getLong("expiresAt") ?: 0L,
acceptedAt = snap.getLong("acceptedAt"),
acceptedByUserId = snap.getString("acceptedByUserId")
acceptedByUserId = snap.getString("acceptedByUserId"),
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
kdfSalt = snap.getString("kdfSalt"),
kdfParams = snap.getString("kdfParams")
)
)
}

View File

@ -1,10 +1,13 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import app.closer.domain.model.QuestionAnswer
import app.closer.domain.model.QuestionMessage
import app.closer.domain.model.QuestionReaction
import app.closer.domain.model.QuestionThread
import app.closer.domain.model.QuestionThreadStatus
import org.json.JSONArray
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
@ -19,7 +22,11 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
@Singleton
class FirestoreQuestionThreadDataSource @Inject constructor(private val db: FirebaseFirestore) {
class FirestoreQuestionThreadDataSource @Inject constructor(
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun threadsRef(coupleId: String) =
db.collection(FirestoreCollections.COUPLES).document(coupleId)
@ -71,6 +78,7 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
suspend fun submitAnswer(coupleId: String, threadId: String, userId: String, answer: QuestionAnswer) {
val now = FieldValue.serverTimestamp()
val aead = encryptionManager.aeadFor(coupleId)
threadsRef(coupleId)
.document(threadId)
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
@ -80,9 +88,13 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
"userId" to answer.userId,
"questionId" to answer.questionId,
"answerType" to answer.answerType,
"writtenText" to answer.writtenText,
"selectedOptionIds" to answer.selectedOptionIds,
"scaleValue" to answer.scaleValue,
"writtenText" to if (aead != null) fieldEncryptor.encryptNullable(answer.writtenText, aead, coupleId) else answer.writtenText,
"selectedOptionIds" to if (aead != null && answer.selectedOptionIds.isNotEmpty())
listOf(fieldEncryptor.encrypt(JSONArray(answer.selectedOptionIds).toString(), aead, coupleId))
else answer.selectedOptionIds,
"scaleValue" to if (aead != null && answer.scaleValue != null)
fieldEncryptor.encrypt(answer.scaleValue.toString(), aead, coupleId)
else answer.scaleValue,
"createdAt" to now,
"updatedAt" to now
)
@ -103,7 +115,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
.collection(FirestoreCollections.QuestionThreads.ANSWERS)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toQuestionAnswer() })
val aead = encryptionManager.aeadFor(coupleId)
trySend(snap.documents.mapNotNull { it.toQuestionAnswer(aead, coupleId) })
}
awaitClose { listener.remove() }
}
@ -111,13 +124,14 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
// ─── Messages ────────────────────────────────────────────────────────────────
suspend fun sendMessage(coupleId: String, threadId: String, message: QuestionMessage) {
val aead = encryptionManager.aeadFor(coupleId)
threadsRef(coupleId)
.document(threadId)
.collection(FirestoreCollections.QuestionThreads.MESSAGES)
.add(
mapOf(
"authorUserId" to message.userId,
"text" to message.text,
"text" to if (aead != null) fieldEncryptor.encrypt(message.text, aead, coupleId) else message.text,
"createdAt" to FieldValue.serverTimestamp()
)
).refAwait()
@ -130,7 +144,8 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
.orderBy("createdAt", Query.Direction.ASCENDING)
.addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(snap.documents.mapNotNull { it.toQuestionMessage() })
val aead = encryptionManager.aeadFor(coupleId)
trySend(snap.documents.mapNotNull { it.toQuestionMessage(aead, coupleId) })
}
awaitClose { listener.remove() }
}
@ -204,26 +219,51 @@ class FirestoreQuestionThreadDataSource @Inject constructor(private val db: Fire
)
@Suppress("UNCHECKED_CAST")
private fun DocumentSnapshot.toQuestionAnswer(): QuestionAnswer? {
private fun DocumentSnapshot.toQuestionAnswer(
aead: com.google.crypto.tink.Aead?,
coupleId: String
): QuestionAnswer? {
val userId = getString("userId") ?: return null
val rawIds = (get("selectedOptionIds") as? List<String>) ?: emptyList()
val selectedOptionIds = if (rawIds.size == 1 && fieldEncryptor.isEncrypted(rawIds[0])) {
val decrypted = fieldEncryptor.decrypt(rawIds[0], aead, coupleId)
if (decrypted != null) runCatching {
val arr = org.json.JSONArray(decrypted)
(0 until arr.length()).map { arr.getString(it) }
}.getOrDefault(emptyList()) else emptyList()
} else rawIds
val rawScale = get("scaleValue")
val scaleValue: Int? = when {
rawScale == null -> null
rawScale is String && fieldEncryptor.isEncrypted(rawScale) ->
fieldEncryptor.decrypt(rawScale, aead, coupleId)?.toIntOrNull()
rawScale is Long -> rawScale.toInt()
rawScale is Int -> rawScale
else -> null
}
return QuestionAnswer(
userId = userId,
questionId = getString("questionId") ?: "",
answerType = getString("answerType") ?: "written",
writtenText = getString("writtenText"),
selectedOptionIds = (get("selectedOptionIds") as? List<String>) ?: emptyList(),
scaleValue = getLong("scaleValue")?.toInt(),
writtenText = fieldEncryptor.decrypt(getString("writtenText"), aead, coupleId),
selectedOptionIds = selectedOptionIds,
scaleValue = scaleValue,
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L,
updatedAt = getTimestamp("updatedAt")?.toDate()?.time ?: 0L
)
}
private fun DocumentSnapshot.toQuestionMessage(): QuestionMessage? {
private fun DocumentSnapshot.toQuestionMessage(
aead: com.google.crypto.tink.Aead?,
coupleId: String
): QuestionMessage? {
val userId = getString("authorUserId") ?: return null
return QuestionMessage(
id = id,
userId = userId,
text = getString("text") ?: "",
text = fieldEncryptor.decrypt(getString("text"), aead, coupleId) ?: "",
createdAt = getTimestamp("createdAt")?.toDate()?.time ?: 0L
)
}

View File

@ -1,5 +1,7 @@
package app.closer.data.remote
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.FieldEncryptor
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.SetOptions
@ -32,7 +34,9 @@ data class WheelRevealDoc(
*/
@Singleton
class FirestoreWheelAnswerDataSource @Inject constructor(
private val db: FirebaseFirestore
private val db: FirebaseFirestore,
private val encryptionManager: CoupleEncryptionManager,
private val fieldEncryptor: FieldEncryptor
) {
private fun doc(coupleId: String, sessionId: String) =
db.collection(FirestoreCollections.COUPLES)
@ -49,11 +53,17 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
questions: List<WheelQuestionRef>,
answers: List<WheelAnswerEntry>
) {
val aead = encryptionManager.aeadFor(coupleId)
val data = mapOf(
"categoryName" to categoryName,
"questions" to questions.map { mapOf("id" to it.id, "text" to it.text) },
"answers" to mapOf(
userId to answers.map { mapOf("questionId" to it.questionId, "display" to it.display) }
userId to answers.map {
mapOf(
"questionId" to it.questionId,
"display" to if (aead != null) fieldEncryptor.encrypt(it.display, aead, coupleId) else it.display
)
}
)
)
doc(coupleId, sessionId).set(data, SetOptions.merge()).await()
@ -61,18 +71,19 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
/** One-shot read — used to detect whether this user has already answered. */
suspend fun getDoc(coupleId: String, sessionId: String): WheelRevealDoc? =
runCatching { parse(doc(coupleId, sessionId).get().await()) }.getOrNull()
runCatching { parse(doc(coupleId, sessionId).get().await(), coupleId) }.getOrNull()
/** Live view of both partners' answers; emits whenever either side submits. */
fun observe(coupleId: String, sessionId: String): Flow<WheelRevealDoc> = callbackFlow {
val reg = doc(coupleId, sessionId).addSnapshotListener { snap, err ->
if (err != null || snap == null) return@addSnapshotListener
trySend(parse(snap))
trySend(parse(snap, coupleId))
}
awaitClose { reg.remove() }
}
private fun parse(snap: DocumentSnapshot): WheelRevealDoc {
private fun parse(snap: DocumentSnapshot, coupleId: String): WheelRevealDoc {
val aead = encryptionManager.aeadFor(coupleId)
val questions = (snap.get("questions") as? List<*>).orEmpty().mapNotNull { item ->
(item as? Map<*, *>)?.let {
WheelQuestionRef(
@ -86,9 +97,10 @@ class FirestoreWheelAnswerDataSource @Inject constructor(
val answersByUser = rawAnswers.orEmpty().mapValues { (_, value) ->
(value as? List<*>).orEmpty().mapNotNull { item ->
(item as? Map<*, *>)?.let {
val rawDisplay = it["display"] as? String ?: ""
WheelAnswerEntry(
questionId = it["questionId"] as? String ?: "",
display = it["display"] as? String ?: ""
display = fieldEncryptor.decrypt(rawDisplay, aead, coupleId) ?: rawDisplay
)
}
}

View File

@ -1,17 +1,23 @@
package app.closer.data.repository
import app.closer.core.crash.CrashReporter
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.RecoveryKeyManager
import app.closer.data.remote.FirestoreCoupleDataSource
import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.data.remote.FirestoreUserDataSource
import app.closer.domain.model.Couple
import app.closer.domain.repository.CoupleRepository
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CoupleRepositoryImpl @Inject constructor(
private val coupleDataSource: FirestoreCoupleDataSource,
private val inviteDataSource: FirestoreInviteDataSource,
private val userDataSource: FirestoreUserDataSource,
private val encryptionManager: CoupleEncryptionManager,
private val crashReporter: CrashReporter
) : CoupleRepository {
@ -24,8 +30,30 @@ class CoupleRepositoryImpl @Inject constructor(
.getOrNull()
}
override suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String> = runCatching {
coupleDataSource.createCouple(inviterUserId, acceptorUserId, inviteCode)
override suspend fun createCouple(
inviterUserId: String,
acceptorUserId: String,
inviteCode: String,
recoveryPhrase: String
): Result<String> = runCatching {
val coupleId = UUID.randomUUID().toString()
// Load wrapped key from invite to unwrap with the acceptor's phrase
val invite = inviteDataSource.getInviteByCode(inviteCode)
val wrappedKey = if (invite?.wrappedCoupleKey != null) {
RecoveryKeyManager.WrappedKey(
cipherB64 = invite.wrappedCoupleKey,
saltB64 = invite.kdfSalt ?: error("Missing kdfSalt on invite"),
params = invite.kdfParams ?: error("Missing kdfParams on invite")
)
} else null
if (wrappedKey != null) {
encryptionManager.unwrapAndStore(coupleId, wrappedKey, recoveryPhrase)
.getOrElse { throw it }
}
coupleDataSource.createCouple(coupleId, inviterUserId, acceptorUserId, inviteCode, wrappedKey)
}
override suspend fun updateStreak(coupleId: String): Result<Unit> = runCatching {
@ -33,6 +61,13 @@ class CoupleRepositoryImpl @Inject constructor(
}
override suspend fun leaveCouple(userId: String): Result<Unit> = runCatching {
val coupleId = userDataSource.getUser(userId)?.coupleId
coupleDataSource.leaveCouple(userId)
if (coupleId != null) encryptionManager.deleteKeyset(coupleId)
}
override suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result<Unit> = runCatching {
val newWrapped = encryptionManager.rewrapWithNewPhrase(coupleId, newPhrase).getOrElse { throw it }
coupleDataSource.updateWrappedKey(coupleId, newWrapped)
}
}

View File

@ -1,20 +1,24 @@
package app.closer.data.repository
import app.closer.crypto.CoupleEncryptionManager
import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.domain.model.Invite
import app.closer.domain.repository.CreateInviteResult
import app.closer.domain.repository.InviteRepository
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class InviteRepositoryImpl @Inject constructor(
private val dataSource: FirestoreInviteDataSource
private val dataSource: FirestoreInviteDataSource,
private val encryptionManager: CoupleEncryptionManager
) : InviteRepository {
override suspend fun createInvite(inviterUserId: String): Result<String> = runCatching {
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
val code = dataSource.generateCode()
dataSource.createInvite(code, inviterUserId)
code
val setup = encryptionManager.setupForNewCouple(code)
dataSource.createInvite(code, inviterUserId, setup.wrapped)
CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase)
}
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching {

View File

@ -8,5 +8,10 @@ data class Couple(
val currentQuestionId: String? = null,
val streakCount: Int = 0,
val lastAnsweredAt: Long? = null,
val activePackId: String? = null
val activePackId: String? = null,
// E2EE: version 0 = plaintext, version 1 = Tink AES256-GCM + Argon2id recovery
val encryptionVersion: Int = 0,
val wrappedCoupleKey: String? = null,
val kdfSalt: String? = null,
val kdfParams: String? = null
)

View File

@ -10,5 +10,9 @@ data class Invite(
val createdAt: Long = System.currentTimeMillis(),
val expiresAt: Long = System.currentTimeMillis() + 24 * 60 * 60 * 1000L,
val acceptedAt: Long? = null,
val acceptedByUserId: String? = null
val acceptedByUserId: String? = null,
// E2EE: wrapped couple keyset fields — null on legacy/unencrypted invites
val wrappedCoupleKey: String? = null,
val kdfSalt: String? = null,
val kdfParams: String? = null
)

View File

@ -4,7 +4,8 @@ import app.closer.domain.model.Couple
interface CoupleRepository {
suspend fun getCoupleForUser(userId: String): Couple?
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String): Result<String>
suspend fun createCouple(inviterUserId: String, acceptorUserId: String, inviteCode: String, recoveryPhrase: String): Result<String>
suspend fun updateStreak(coupleId: String): Result<Unit>
suspend fun leaveCouple(userId: String): Result<Unit>
suspend fun changeRecoveryPhrase(coupleId: String, newPhrase: String): Result<Unit>
}

View File

@ -2,8 +2,10 @@ package app.closer.domain.repository
import app.closer.domain.model.Invite
data class CreateInviteResult(val code: String, val recoveryPhrase: String)
interface InviteRepository {
suspend fun createInvite(inviterUserId: String): Result<String>
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
suspend fun getInviteByCode(code: String): Result<Invite?>
suspend fun markAccepted(code: String, acceptorUserId: String, coupleId: String): Result<Unit>
}

View File

@ -77,6 +77,12 @@ fun HomeScreen(
}
}
LaunchedEffect(state.needsRecovery) {
if (state.needsRecovery) {
onNavigate(app.closer.core.navigation.AppRoute.RECOVERY)
}
}
HomeContent(
state = state,
snackbarHostState = snackbarHostState,

View File

@ -3,6 +3,8 @@ package app.closer.ui.home
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.EncryptionStatus
import app.closer.domain.model.LocalAnswer
import app.closer.domain.model.Question
import app.closer.domain.model.QuestionCategory
@ -73,7 +75,8 @@ data class HomeUiState(
val isPaired: Boolean = false,
val primaryAction: HomeAction? = null,
val secondaryActions: List<HomeAction> = emptyList(),
val partnerLeftEvent: Boolean = false
val partnerLeftEvent: Boolean = false,
val needsRecovery: Boolean = false
)
@HiltViewModel
@ -83,6 +86,7 @@ class HomeViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository,
private val encryptionManager: CoupleEncryptionManager,
private val db: FirebaseFirestore
) : ViewModel() {
@ -123,6 +127,8 @@ class HomeViewModel @Inject constructor(
.onFailure { Log.w(TAG, "Could not load partner display name", it) }
.getOrNull()
}
val needsRecovery = couple != null &&
encryptionManager.checkStatus(couple) == EncryptionStatus.NEEDS_RECOVERY
_uiState.update {
it.copy(
isLoading = false,
@ -131,7 +137,8 @@ class HomeViewModel @Inject constructor(
partnerName = partnerName,
streakCount = couple?.streakCount ?: 0,
isPaired = couple != null,
partnerLeftEvent = false
partnerLeftEvent = false,
needsRecovery = needsRecovery
).withHomeActions()
}
} catch (e: Exception) {
@ -182,6 +189,12 @@ class HomeViewModel @Inject constructor(
_uiState.update { it.copy(partnerLeftEvent = false) }
}
/** Called after the recovery flow completes so the banner goes away. */
fun onRecoveryCompleted() {
_uiState.update { it.copy(needsRecovery = false) }
loadHome()
}
private fun observeAnswers() {
viewModelScope.launch {
localAnswerRepository.observeAnswers().collect { answers ->

View File

@ -23,6 +23,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Share
import androidx.compose.foundation.border
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
@ -233,6 +235,69 @@ fun CreateInviteScreen(
textAlign = TextAlign.Center
)
// Recovery phrase — shown once; user must write it down
state.recoveryPhrase?.let { phrase ->
Spacer(Modifier.height(28.dp))
Card(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, SettingsPrimaryDeep.copy(alpha = 0.3f), RoundedCornerShape(16.dp)),
colors = CardDefaults.cardColors(containerColor = SettingsSoft),
elevation = CardDefaults.cardElevation(0.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Filled.Lock,
contentDescription = null,
tint = SettingsPrimaryDeep,
modifier = Modifier.size(16.dp)
)
Text(
"Recovery phrase",
style = MaterialTheme.typography.labelMedium,
color = SettingsPrimaryDeep,
fontWeight = FontWeight.SemiBold
)
}
Text(
phrase,
style = MaterialTheme.typography.bodyMedium,
color = SettingsInk,
fontWeight = FontWeight.Medium
)
Text(
"Write this down and share it with your partner. You'll both need it to access your answers on a new phone.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted
)
Button(
onClick = {
clipboard.setText(AnnotatedString(phrase))
scope.launch { snackbar.showSnackbar("Recovery phrase copied!") }
},
modifier = Modifier.fillMaxWidth().height(44.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(
containerColor = SettingsPrimary.copy(alpha = 0.12f),
contentColor = SettingsPrimaryDeep
)
) {
Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(6.dp))
Text("Copy phrase", style = MaterialTheme.typography.labelMedium)
}
}
}
}
Spacer(Modifier.height(28.dp))
TextButton(

View File

@ -17,6 +17,7 @@ import javax.inject.Inject
data class CreateInviteUiState(
val isLoading: Boolean = true,
val inviteCode: String? = null,
val recoveryPhrase: String? = null,
val error: String? = null,
val navigateTo: String? = null
)
@ -43,8 +44,8 @@ class CreateInviteViewModel @Inject constructor(
return@launch
}
inviteRepository.createInvite(userId)
.onSuccess { code ->
_uiState.update { it.copy(isLoading = false, inviteCode = code) }
.onSuccess { result ->
_uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
}
.onFailure { e ->
_uiState.update { it.copy(isLoading = false, error = e.message ?: "Couldn't create invite. Please try again.") }

View File

@ -25,6 +25,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@ -41,6 +43,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -142,7 +146,39 @@ fun InviteConfirmScreen(
color = SettingsMuted
)
Spacer(Modifier.height(32.dp))
Spacer(Modifier.height(24.dp))
// Recovery phrase input — only shown for encrypted invites
if (state.isEncryptedInvite) {
OutlinedTextField(
value = state.recoveryPhrase,
onValueChange = viewModel::onPhraseChanged,
modifier = Modifier.fillMaxWidth(),
label = { Text("Recovery phrase") },
placeholder = { Text("word word word word word word") },
supportingText = {
Text(
"Your partner sees this when they create the invite.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted
)
},
singleLine = false,
minLines = 2,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = SettingsPrimaryDeep,
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
focusedLabelColor = SettingsPrimaryDeep,
unfocusedLabelColor = SettingsMuted,
cursorColor = SettingsPrimaryDeep
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
)
Spacer(Modifier.height(16.dp))
} else {
Spacer(Modifier.height(8.dp))
}
Button(
onClick = viewModel::confirmPairing,

View File

@ -21,9 +21,11 @@ import javax.inject.Inject
data class InviteConfirmUiState(
val isLoading: Boolean = true,
val inviterName: String? = null,
val recoveryPhrase: String = "",
val error: String? = null,
val navigateTo: String? = null,
val isConfirming: Boolean = false
val isConfirming: Boolean = false,
val isEncryptedInvite: Boolean = false
)
@HiltViewModel
@ -51,7 +53,13 @@ class InviteConfirmViewModel @Inject constructor(
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
.getOrNull()
}
_uiState.update { it.copy(isLoading = false, inviterName = inviterName ?: "your partner") }
_uiState.update {
it.copy(
isLoading = false,
inviterName = inviterName ?: "your partner",
isEncryptedInvite = invite?.wrappedCoupleKey != null
)
}
}
.onFailure {
_uiState.update { it.copy(isLoading = false, error = "Couldn't load invite details. Please go back and try again.") }
@ -59,6 +67,8 @@ class InviteConfirmViewModel @Inject constructor(
}
}
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) }
fun confirmPairing() {
val acceptorId = authRepository.currentUserId ?: run {
_uiState.update { it.copy(error = "Not signed in.") }
@ -68,15 +78,26 @@ class InviteConfirmViewModel @Inject constructor(
_uiState.update { it.copy(error = "Invite not loaded yet.") }
return
}
val phrase = _uiState.value.recoveryPhrase.trim()
if (invite.wrappedCoupleKey != null && phrase.isBlank()) {
_uiState.update { it.copy(error = "Enter the recovery phrase your partner shared with you.") }
return
}
_uiState.update { it.copy(isConfirming = true, error = null) }
viewModelScope.launch {
coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode)
coupleRepository.createCouple(invite.inviterUserId, acceptorId, inviteCode, phrase)
.onSuccess { coupleId ->
inviteRepository.markAccepted(inviteCode, acceptorId, coupleId)
_uiState.update { it.copy(isConfirming = false, navigateTo = AppRoute.HOME) }
}
.onFailure { e ->
_uiState.update { it.copy(isConfirming = false, error = e.message ?: "Couldn't complete pairing. Please try again.") }
val msg = when {
e.message?.contains("AEADBadTag", ignoreCase = true) == true ||
e.message?.contains("decryption", ignoreCase = true) == true ->
"That phrase doesn't match. Ask your partner to recheck it."
else -> e.message ?: "Couldn't complete pairing. Please try again."
}
_uiState.update { it.copy(isConfirming = false, error = msg) }
}
}
}

View File

@ -0,0 +1,164 @@
package app.closer.ui.pairing
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.ui.components.StatusGlyph
import app.closer.ui.settings.SettingsBackgroundBrush
import app.closer.ui.settings.SettingsInk
import app.closer.ui.settings.SettingsMuted
import app.closer.ui.settings.SettingsOnPrimary
import app.closer.ui.settings.SettingsPrimary
import app.closer.ui.settings.SettingsPrimaryDeep
@Composable
fun RecoveryScreen(
onRecovered: () -> Unit,
viewModel: RecoveryViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(state.success) {
if (state.success) onRecovered()
}
LaunchedEffect(state.error) {
state.error?.let { snackbar.showSnackbar(it); viewModel.dismissError() }
}
Scaffold(
snackbarHost = { SnackbarHost(snackbar) },
containerColor = Color.Transparent,
modifier = Modifier.background(SettingsBackgroundBrush)
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.safeDrawingPadding()
.navigationBarsPadding()
.verticalScroll(rememberScrollState())
.padding(padding)
.padding(horizontal = 28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(48.dp))
StatusGlyph(
icon = Icons.Filled.Lock,
tint = SettingsPrimaryDeep,
container = SettingsPrimary.copy(alpha = 0.12f)
)
Spacer(Modifier.height(20.dp))
Text(
"Unlock your history",
style = MaterialTheme.typography.headlineSmall,
color = SettingsInk,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
Text(
"Enter the recovery phrase you and your partner were shown when pairing. Either partner's phrase works.",
style = MaterialTheme.typography.bodyMedium,
color = SettingsMuted,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(28.dp))
OutlinedTextField(
value = state.phrase,
onValueChange = viewModel::onPhraseChanged,
modifier = Modifier.fillMaxWidth(),
label = { Text("Recovery phrase") },
placeholder = { Text("word word word word word word") },
singleLine = false,
minLines = 2,
shape = RoundedCornerShape(12.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = SettingsPrimaryDeep,
unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f),
focusedLabelColor = SettingsPrimaryDeep,
unfocusedLabelColor = SettingsMuted,
cursorColor = SettingsPrimaryDeep
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
)
Spacer(Modifier.height(24.dp))
Button(
onClick = viewModel::recover,
enabled = !state.isLoading,
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp),
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.buttonColors(
containerColor = SettingsPrimary,
contentColor = SettingsOnPrimary
)
) {
if (state.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = SettingsOnPrimary,
strokeWidth = 2.dp
)
} else {
Text("Unlock answers", style = MaterialTheme.typography.labelLarge)
}
}
Spacer(Modifier.height(20.dp))
Text(
"If you've lost your phrase and don't have another device, your encrypted history cannot be recovered — this is by design.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted,
textAlign = TextAlign.Center
)
}
}
}

View File

@ -0,0 +1,75 @@
package app.closer.ui.pairing
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
data class RecoveryUiState(
val phrase: String = "",
val isLoading: Boolean = false,
val error: String? = null,
val success: Boolean = false
)
@HiltViewModel
class RecoveryViewModel @Inject constructor(
private val authRepository: AuthRepository,
private val coupleRepository: CoupleRepository,
private val encryptionManager: CoupleEncryptionManager
) : ViewModel() {
private val _uiState = MutableStateFlow(RecoveryUiState())
val uiState: StateFlow<RecoveryUiState> = _uiState.asStateFlow()
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(phrase = phrase, error = null) }
fun recover() {
val phrase = _uiState.value.phrase.trim()
if (phrase.isBlank()) {
_uiState.update { it.copy(error = "Enter your recovery phrase.") }
return
}
_uiState.update { it.copy(isLoading = true, error = null) }
viewModelScope.launch {
val userId = authRepository.currentUserId ?: run {
_uiState.update { it.copy(isLoading = false, error = "Not signed in.") }
return@launch
}
val couple = coupleRepository.getCoupleForUser(userId)
if (couple == null || couple.wrappedCoupleKey == null) {
_uiState.update { it.copy(isLoading = false, error = "Couldn't load couple data. Try again.") }
return@launch
}
val wrapped = RecoveryKeyManager.WrappedKey(
cipherB64 = couple.wrappedCoupleKey,
saltB64 = couple.kdfSalt ?: "",
params = couple.kdfParams ?: ""
)
encryptionManager.unwrapAndStore(couple.id, wrapped, phrase)
.onSuccess {
_uiState.update { it.copy(isLoading = false, success = true) }
}
.onFailure { e ->
val msg = when {
e.message?.contains("AEADBadTag", ignoreCase = true) == true ||
e.message?.contains("decryption", ignoreCase = true) == true ->
"That phrase doesn't match. Check with your partner and try again."
else -> "Recovery failed. Please check your phrase and try again."
}
_uiState.update { it.copy(isLoading = false, error = msg) }
}
}
}
fun dismissError() = _uiState.update { it.copy(error = null) }
}

View File

@ -80,6 +80,11 @@ service cloud.firestore {
match /notification_queue/{notificationId} {
allow read, write: if false;
}
// FCM registration tokens: owner can read/write their own tokens.
match /fcmTokens/{tokenId} {
allow read, write: if isOwner(uid);
}
}
// ── Date ideas (read-only catalog) ─────────────────────────────────────────
@ -123,7 +128,8 @@ service cloud.firestore {
&& request.resource.data.expiresAt is timestamp
&& request.time < request.resource.data.expiresAt
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'expiresAt'])
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt']);
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']);
// Update (accept): proper validation for changing status to accepted.
// If coupleId is supplied, it must reference an existing couple where
@ -163,17 +169,26 @@ service cloud.firestore {
// Read: both members can read
allow read: if isCouplesMember(coupleId);
// Create: only via invite flow (server-side or admin SDK).
// Admin SDK bypasses rules; direct client writes are denied.
allow create: if false;
// Create: acceptor creates the couple doc during pairing (client-side).
// Must be a member of the couple and include required fields.
allow create: if isSignedIn()
&& request.auth.uid in request.resource.data.userIds
&& request.resource.data.keys().hasAll(['id', 'userIds', 'inviteCode', 'createdAt', 'streakCount'])
&& request.resource.data.keys().hasOnly([
'id', 'userIds', 'inviteCode', 'createdAt', 'streakCount',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'encryptionVersion']);
// Update: field-level restrictions
// - user IDs are immutable (cannot change who is in the couple)
// - invite code is immutable (cannot change the code)
// - createdAt is immutable (cannot change when the couple was formed)
// - encryptionVersion is monotonically non-decreasing (cannot downgrade)
// - wrappedCoupleKey/kdfSalt/kdfParams: mutable by members (passphrase change)
// - All other fields (including streakCount and lastAnsweredAt): both members can update
allow update: if isCouplesMember(coupleId)
&& isImmutable(['userIds', 'inviteCode', 'createdAt']);
&& isImmutable(['userIds', 'inviteCode', 'createdAt'])
&& (resource.data.encryptionVersion == null
|| request.resource.data.encryptionVersion >= resource.data.encryptionVersion);
// Delete: server-only (admin SDK only). Admin SDK bypasses rules.
allow delete: if false;