feat: E2EE recovery flow, iOS parity updates, onboarding + pairing polish

This commit is contained in:
null 2026-06-21 11:20:48 -05:00
parent 62d99505c9
commit af70280daa
30 changed files with 353 additions and 273 deletions

View File

@ -41,19 +41,24 @@ class CoupleEncryptionManager @Inject constructor(
private val keyManager: RecoveryKeyManager private val keyManager: RecoveryKeyManager
) { ) {
/** /**
* Called by the inviter when creating an invite. * Generates a new couple keyset + recovery phrase but does NOT store them yet
* Generates a new couple keyset + recovery phrase, wraps the keyset, * storage is deferred until the server confirms the invite code via [storeInviteSetup].
* and stores it locally under the invite slot (coupleId unknown yet). * This avoids storing the keyset under a client-generated code that may differ from
* the code the server ultimately uses (which would break reconciliation).
*/ */
suspend fun setupForNewCouple(inviteCode: String): SetupResult = withContext(Dispatchers.Default) { suspend fun setupForNewCouple(): SetupResult = withContext(Dispatchers.Default) {
val phrase = keyManager.generateRecoveryPhrase() val phrase = keyManager.generateRecoveryPhrase()
val handle = keyManager.newCoupleKeyset() val handle = keyManager.newCoupleKeyset()
val wrapped = keyManager.wrap(handle, phrase) val wrapped = keyManager.wrap(handle, phrase)
keyStore.storeInviteKeyset(inviteCode, handle)
keyStore.storeInvitePhrase(inviteCode, phrase)
SetupResult(handle, wrapped, phrase) SetupResult(handle, wrapped, phrase)
} }
/** Persists a [SetupResult] under the server-confirmed [inviteCode]. */
fun storeInviteSetup(inviteCode: String, result: SetupResult) {
keyStore.storeInviteKeyset(inviteCode, result.handle)
keyStore.storeInvitePhrase(inviteCode, result.recoveryPhrase)
}
/** /**
* Called by the acceptor during pairing, and on any device during recovery. * Called by the acceptor during pairing, and on any device during recovery.
* Derives the wrap key from [phrase], unwraps the keyset, and stores it * Derives the wrap key from [phrase], unwraps the keyset, and stores it

View File

@ -92,6 +92,30 @@ class RecoveryKeyManager @Inject constructor() {
return out return out
} }
/**
* Encrypts [phrase] with a key derived from [inviteCode] via Argon2id + AES-256-GCM.
* Output format: base64(salt[16] || gcm_ciphertext). Argon2id makes offline brute-force
* infeasible even for the 6-char code space (~30 bits entropy).
*/
fun encryptPhraseWithCode(inviteCode: String, phrase: String): String {
val salt = ByteArray(SALT_BYTES).also { SecureRandom().nextBytes(it) }
val key = deriveKey(inviteCode, salt)
val ct = AesGcmJce(key).encrypt(phrase.toByteArray(Charsets.UTF_8), PHRASE_CIPHER_AAD)
return Base64.getEncoder().encodeToString(salt + ct)
}
/**
* Decrypts a ciphertext produced by [encryptPhraseWithCode] using [inviteCode].
* Throws if the code is wrong or the ciphertext is malformed.
*/
fun decryptPhraseWithCode(inviteCode: String, ciphertextB64: String): String {
val payload = Base64.getDecoder().decode(ciphertextB64)
val salt = payload.copyOf(SALT_BYTES)
val ct = payload.copyOfRange(SALT_BYTES, payload.size)
val key = deriveKey(inviteCode, salt)
return String(AesGcmJce(key).decrypt(ct, PHRASE_CIPHER_AAD), Charsets.UTF_8)
}
companion object { companion object {
private const val PHRASE_WORD_COUNT = 10 private const val PHRASE_WORD_COUNT = 10
private const val SALT_BYTES = 16 private const val SALT_BYTES = 16
@ -101,6 +125,7 @@ class RecoveryKeyManager @Inject constructor() {
private const val ARGON2_PARALLELISM = 1 private const val ARGON2_PARALLELISM = 1
private const val PARAMS_TAG = "argon2id;v=19;m=47104;t=3;p=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) private val WRAP_AAD = "closer_couple_key".toByteArray(Charsets.UTF_8)
private val PHRASE_CIPHER_AAD = "closer_invite_phrase".toByteArray(Charsets.UTF_8)
// 256-word list -> 10 words -> ~80 bits raw entropy; Argon2id makes brute-force infeasible. // 256-word list -> 10 words -> ~80 bits raw entropy; Argon2id makes brute-force infeasible.
val WORDLIST = arrayOf( val WORDLIST = arrayOf(

View File

@ -1,42 +1,39 @@
package app.closer.data.remote package app.closer.data.remote
import app.closer.crypto.RecoveryKeyManager import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Invite import app.closer.domain.repository.AcceptInviteResult
import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.functions.FirebaseFunctions import com.google.firebase.functions.FirebaseFunctions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await import kotlinx.coroutines.tasks.await
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.random.Random
@Singleton @Singleton
class FirestoreInviteDataSource @Inject constructor( class FirestoreInviteDataSource @Inject constructor(
private val db: FirebaseFirestore, private val db: FirebaseFirestore,
private val functions: FirebaseFunctions private val functions: FirebaseFunctions
) { ) {
private fun inviteRef(code: String) = db.collection(FirestoreCollections.INVITES).document(code) /**
* Creates an invite server-side. [code] is client-generated and used as both the
fun generateCode(): String = (1..6) * Firestore document ID and the KDF input for phrase encryption; the server validates
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] } * uniqueness and returns the confirmed code. [encryptedRecoveryPhrase] is an
.joinToString("") * Argon2id+AES-GCM blob produced by [RecoveryKeyManager.encryptPhraseWithCode]
* the server stores it opaquely and never sees the plaintext phrase.
*/
suspend fun createInvite( suspend fun createInvite(
code: String, code: String,
inviterUserId: String,
wrappedKey: RecoveryKeyManager.WrappedKey, wrappedKey: RecoveryKeyManager.WrappedKey,
recoveryPhrase: String encryptedRecoveryPhrase: String
): CreateInviteResponse { ): CreateInviteResponse {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val result = functions.getHttpsCallable("createInviteCallable") val result = functions.getHttpsCallable("createInviteCallable")
.call( .call(
mapOf( mapOf(
"code" to code,
"wrappedCoupleKey" to wrappedKey.cipherB64, "wrappedCoupleKey" to wrappedKey.cipherB64,
"kdfSalt" to wrappedKey.saltB64, "kdfSalt" to wrappedKey.saltB64,
"kdfParams" to wrappedKey.params, "kdfParams" to wrappedKey.params,
"recoveryPhrase" to recoveryPhrase "encryptedRecoveryPhrase" to encryptedRecoveryPhrase
) )
) )
.await() .await()
@ -54,43 +51,13 @@ class FirestoreInviteDataSource @Inject constructor(
val expiresAt: com.google.firebase.Timestamp val expiresAt: com.google.firebase.Timestamp
) )
suspend fun getInviteByCode(code: String): Invite? =
suspendCancellableCoroutine { cont ->
inviteRef(code).get()
.addOnSuccessListener { snap ->
if (!snap.exists()) { cont.resume(null); return@addOnSuccessListener }
cont.resume(
Invite(
id = snap.id,
code = snap.getString("code") ?: snap.id,
inviterUserId = snap.getString("inviterUserId") ?: "",
inviteeEmail = snap.getString("inviteeEmail"),
coupleId = snap.getString("coupleId"),
status = snap.getString("status") ?: "pending",
createdAt = snap.getTimestamp("createdAt")?.toDate()?.time
?: snap.getLong("createdAt") ?: 0L,
expiresAt = snap.getTimestamp("expiresAt")?.toDate()?.time
?: snap.getLong("expiresAt") ?: 0L,
acceptedAt = snap.getTimestamp("acceptedAt")?.toDate()?.time
?: snap.getLong("acceptedAt"),
acceptedByUserId = snap.getString("acceptedByUserId"),
wrappedCoupleKey = snap.getString("wrappedCoupleKey"),
kdfSalt = snap.getString("kdfSalt"),
kdfParams = snap.getString("kdfParams")
)
)
}
.addOnFailureListener { cont.resumeWithException(it) }
}
/** /**
* Accepts an invite server-side via the [acceptInviteCallable] Cloud Function. * Accepts an invite server-side. Returns [AcceptInviteResult] with
* * [AcceptInviteResult.recoveryPhrase] holding the raw encrypted blob from the server;
* The client no longer reads the invite document directly (issue #9 fix). * the caller ([InviteRepositoryImpl]) decrypts it with the invite code before
* The function reads the recovery phrase from the invite doc and returns it, * surfacing it to the domain layer.
* so the acceptor never needs to type it manually.
*/ */
suspend fun acceptInvite(code: String): app.closer.domain.repository.AcceptInviteResult { suspend fun acceptInvite(code: String): AcceptInviteResult {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val result = functions.getHttpsCallable("acceptInviteCallable") val result = functions.getHttpsCallable("acceptInviteCallable")
.call(mapOf("code" to code)) .call(mapOf("code" to code))
@ -108,9 +75,10 @@ class FirestoreInviteDataSource @Inject constructor(
?: throw IllegalStateException("Missing kdfSalt in acceptInvite response") ?: throw IllegalStateException("Missing kdfSalt in acceptInvite response")
val kdfParams = data["kdfParams"] as? String val kdfParams = data["kdfParams"] as? String
?: throw IllegalStateException("Missing kdfParams in acceptInvite response") ?: throw IllegalStateException("Missing kdfParams in acceptInvite response")
val recoveryPhrase = data["recoveryPhrase"] as? String // Raw encrypted blob — repository decrypts before returning to callers.
val encryptedRecoveryPhrase = data["encryptedRecoveryPhrase"] as? String
return app.closer.domain.repository.AcceptInviteResult( return AcceptInviteResult(
coupleId = coupleId, coupleId = coupleId,
inviterUserId = inviterUserId, inviterUserId = inviterUserId,
wrappedKey = RecoveryKeyManager.WrappedKey( wrappedKey = RecoveryKeyManager.WrappedKey(
@ -118,11 +86,7 @@ class FirestoreInviteDataSource @Inject constructor(
saltB64 = kdfSalt, saltB64 = kdfSalt,
params = kdfParams params = kdfParams
), ),
recoveryPhrase = recoveryPhrase recoveryPhrase = encryptedRecoveryPhrase
) )
} }
companion object {
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
}
} }

View File

@ -1,35 +1,66 @@
package app.closer.data.repository package app.closer.data.repository
import app.closer.crypto.CoupleEncryptionManager import app.closer.crypto.CoupleEncryptionManager
import app.closer.crypto.RecoveryKeyManager
import app.closer.data.remote.FirestoreInviteDataSource import app.closer.data.remote.FirestoreInviteDataSource
import app.closer.domain.model.Invite
import app.closer.domain.repository.AcceptInviteResult import app.closer.domain.repository.AcceptInviteResult
import app.closer.domain.repository.CreateInviteResult import app.closer.domain.repository.CreateInviteResult
import app.closer.domain.repository.InviteRepository import app.closer.domain.repository.InviteRepository
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.random.Random
@Singleton @Singleton
class InviteRepositoryImpl @Inject constructor( class InviteRepositoryImpl @Inject constructor(
private val dataSource: FirestoreInviteDataSource, private val dataSource: FirestoreInviteDataSource,
private val encryptionManager: CoupleEncryptionManager private val encryptionManager: CoupleEncryptionManager,
private val keyManager: RecoveryKeyManager
) : InviteRepository { ) : InviteRepository {
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching { override suspend fun createInvite(): Result<CreateInviteResult> = runCatching {
val localCode = dataSource.generateCode() // Generate the keyset + phrase once. Only the code (and the per-code encrypted phrase)
val setup = encryptionManager.setupForNewCouple(localCode) // changes on the rare collision retry — the keyset itself stays the same.
// The server is the source of truth for the final code; it may differ val setup = encryptionManager.setupForNewCouple()
// from localCode if a collision occurs server-side. The returned code is var lastError: Throwable? = null
// what the partner must enter. for (attempt in 0 until MAX_CODE_ATTEMPTS) {
val response = dataSource.createInvite(localCode, inviterUserId, setup.wrapped, setup.recoveryPhrase) val code = generateCode()
CreateInviteResult(code = response.code, recoveryPhrase = setup.recoveryPhrase) val encryptedPhrase = keyManager.encryptPhraseWithCode(code, setup.recoveryPhrase)
val response = runCatching { dataSource.createInvite(code, setup.wrapped, encryptedPhrase) }
response.onSuccess { r ->
encryptionManager.storeInviteSetup(r.code, setup)
return@runCatching CreateInviteResult(r.code, setup.recoveryPhrase)
}
response.onFailure { e ->
if (!isCodeConflict(e)) throw e
lastError = e
}
}
throw lastError ?: IllegalStateException("Could not generate a unique invite code")
} }
override suspend fun getInviteByCode(code: String): Result<Invite?> = runCatching { override suspend fun acceptInvite(code: String): Result<AcceptInviteResult> = runCatching {
dataSource.getInviteByCode(code) val raw = dataSource.acceptInvite(code)
// raw.recoveryPhrase holds the encrypted blob from the server; decrypt it here so
// callers always receive the plaintext phrase (or null for iOS/plaintext couples).
val phrase = raw.recoveryPhrase?.let {
runCatching { keyManager.decryptPhraseWithCode(code, it) }.getOrNull()
}
raw.copy(recoveryPhrase = phrase)
} }
override suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult> = runCatching { private fun isCodeConflict(e: Throwable): Boolean {
dataSource.acceptInvite(code) val msg = e.message ?: return false
return msg.contains("already-exists", ignoreCase = true) ||
msg.contains("ALREADY_EXISTS", ignoreCase = true)
}
private fun generateCode(): String = (1..CODE_LENGTH)
.map { CODE_CHARS[Random.nextInt(CODE_CHARS.length)] }
.joinToString("")
companion object {
private const val CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
private const val CODE_LENGTH = 6
private const val MAX_CODE_ATTEMPTS = 10
} }
} }

View File

@ -1,7 +1,6 @@
package app.closer.domain.repository package app.closer.domain.repository
import app.closer.crypto.RecoveryKeyManager import app.closer.crypto.RecoveryKeyManager
import app.closer.domain.model.Invite
data class CreateInviteResult(val code: String, val recoveryPhrase: String) data class CreateInviteResult(val code: String, val recoveryPhrase: String)
@ -10,8 +9,9 @@ data class CreateInviteResult(val code: String, val recoveryPhrase: String)
* *
* @property inviterUserId The UID of the partner who created the invite. * @property inviterUserId The UID of the partner who created the invite.
* @property wrappedKey The encrypted couple key the acceptor must unwrap. * @property wrappedKey The encrypted couple key the acceptor must unwrap.
* @property recoveryPhrase The phrase stored by the inviter; returned by the server so the acceptor * @property recoveryPhrase The recovery phrase, decrypted client-side from the server's
* never needs to type it manually. * encryptedRecoveryPhrase blob using the invite code. Null for iOS-originated invites
* (encryptionVersion=0) until iOS implements E2EE parity.
*/ */
data class AcceptInviteResult( data class AcceptInviteResult(
val coupleId: String, val coupleId: String,
@ -21,7 +21,6 @@ data class AcceptInviteResult(
) )
interface InviteRepository { interface InviteRepository {
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> suspend fun createInvite(): Result<CreateInviteResult>
suspend fun getInviteByCode(code: String): Result<Invite?> suspend fun acceptInvite(code: String): Result<AcceptInviteResult>
suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult>
} }

View File

@ -1,5 +1,6 @@
package app.closer.ui.answers package app.closer.ui.answers
import app.closer.R
import app.closer.ui.theme.closerBackgroundBrush import app.closer.ui.theme.closerBackgroundBrush
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -162,7 +163,8 @@ private fun AnswerHistoryContent(
title = "Nothing here yet", title = "Nothing here yet",
body = "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here.", body = "Answer today's question or pick a prompt from a pack. Every answer you save will wait for you here.",
actionLabel = "Today's question", actionLabel = "Today's question",
onAction = onDailyQuestion onAction = onDailyQuestion,
illustrationResId = R.drawable.illustration_couple_history
) )
} }
} else { } else {

View File

@ -1,15 +1,24 @@
package app.closer.ui.components package app.closer.ui.components
import app.closer.ui.theme.closerCardColor import app.closer.ui.theme.closerCardColor
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable @Composable
fun EmptyState( fun EmptyState(
@ -17,7 +26,9 @@ fun EmptyState(
body: String, body: String,
actionLabel: String? = null, actionLabel: String? = null,
onAction: (() -> Unit)? = null, onAction: (() -> Unit)? = null,
modifier: Modifier = Modifier modifier: Modifier = Modifier,
@DrawableRes illustrationResId: Int? = null,
illustrationSize: Dp = 132.dp
) { ) {
CloserCard( CloserCard(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@ -27,6 +38,16 @@ fun EmptyState(
modifier = Modifier.padding(CloserSpacing.Xl), modifier = Modifier.padding(CloserSpacing.Xl),
verticalArrangement = Arrangement.spacedBy(CloserSpacing.Md) verticalArrangement = Arrangement.spacedBy(CloserSpacing.Md)
) { ) {
illustrationResId?.let { resId ->
Image(
painter = painterResource(resId),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(illustrationSize)
.clip(RoundedCornerShape(CloserRadii.Tile))
)
}
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,

View File

@ -1,10 +1,9 @@
package app.closer.ui.onboarding package app.closer.ui.onboarding
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -298,71 +297,15 @@ private fun CtaSlide(onNavigate: (String) -> Unit) {
@Composable @Composable
private fun AnswerPreviewVisual() { private fun AnswerPreviewVisual() {
Column( Image(
horizontalAlignment = Alignment.CenterHorizontally, painter = painterResource(R.drawable.illustration_couple_onboarding),
verticalArrangement = Arrangement.spacedBy(12.dp) contentDescription = null,
) { contentScale = ContentScale.Fit,
// Mock question card modifier = Modifier
Card( .fillMaxWidth(0.8f)
modifier = Modifier.fillMaxWidth(), .aspectRatio(2f / 3f)
shape = RoundedCornerShape(20.dp), .clip(RoundedCornerShape(28.dp))
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(horizontal = 20.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "What's something you want more of in your relationship?",
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Medium),
color = Color(0xFF1A1A2E),
textAlign = TextAlign.Center
)
// Two mock answer bubbles
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MockAnswerBubble("Quality time", selected = true, modifier = Modifier.weight(1f))
MockAnswerBubble("Adventure", selected = false, modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
MockAnswerBubble("Deep talks", selected = false, modifier = Modifier.weight(1f))
MockAnswerBubble("Playfulness", selected = false, modifier = Modifier.weight(1f))
}
}
}
}
}
@Composable
private fun MockAnswerBubble(label: String, selected: Boolean, modifier: Modifier = Modifier) {
val bg by animateColorAsState(
if (selected) CloserPalette.PurpleDeep else Color(0xFFF4F0FF),
animationSpec = tween(200)
) )
val fg by animateColorAsState(
if (selected) Color.White else Color(0xFF56306F),
animationSpec = tween(200)
)
Surface(
modifier = modifier,
shape = RoundedCornerShape(12.dp),
color = bg
) {
Text(
text = label,
modifier = Modifier.padding(vertical = 10.dp, horizontal = 6.dp),
style = MaterialTheme.typography.labelMedium,
color = fg,
textAlign = TextAlign.Center,
maxLines = 1
)
}
} }
@Composable @Composable

View File

@ -43,7 +43,7 @@ class CreateInviteViewModel @Inject constructor(
_uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.HOME) } _uiState.update { it.copy(isLoading = false, navigateTo = AppRoute.HOME) }
return@launch return@launch
} }
inviteRepository.createInvite(userId) inviteRepository.createInvite()
.onSuccess { result -> .onSuccess { result ->
_uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) } _uiState.update { it.copy(isLoading = false, inviteCode = result.code, recoveryPhrase = result.recoveryPhrase) }
} }

View File

@ -48,13 +48,13 @@ class InviteConfirmViewModel @Inject constructor(
} }
fun confirmPairing() { fun confirmPairing() {
val acceptorId = authRepository.currentUserId ?: run { if (authRepository.currentUserId == null) {
_uiState.update { it.copy(error = "Not signed in.") } _uiState.update { it.copy(error = "Not signed in.") }
return return
} }
_uiState.update { it.copy(isConfirming = true, error = null) } _uiState.update { it.copy(isConfirming = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
inviteRepository.acceptInvite(inviteCode, acceptorId) inviteRepository.acceptInvite(inviteCode)
.onSuccess { result -> .onSuccess { result ->
val phrase = result.recoveryPhrase val phrase = result.recoveryPhrase
if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) { if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {

View File

@ -1,5 +1,6 @@
package app.closer.ui.pairing package app.closer.ui.pairing
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -27,9 +28,13 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import app.closer.R
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import app.closer.ui.auth.AuthBackgroundBrush import app.closer.ui.auth.AuthBackgroundBrush
import app.closer.ui.auth.AuthInk import app.closer.ui.auth.AuthInk
@ -76,6 +81,17 @@ fun PairPromptScreen(
Spacer(Modifier.height(20.dp)) Spacer(Modifier.height(20.dp))
Image(
painter = painterResource(R.drawable.illustration_couple_invite),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 176.dp, height = 230.dp)
.clip(RoundedCornerShape(28.dp))
)
Spacer(Modifier.height(20.dp))
Text( Text(
text = "Now bring\nyour person in.", text = "Now bring\nyour person in.",
style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold),

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -1,7 +1,7 @@
# Closer — Private MVP QA Checklist # Closer — Private MVP QA Checklist
> Manual testing checklist for the internal MVP build. Covers every top-level flow in the app and notes known gaps discovered during the 2025-06 QA pass. > Manual testing checklist for the internal MVP build. Covers every top-level flow in the app and notes known gaps discovered during the 2025-06 QA pass.
> Last updated: 2026-06-21 — reflects pairing security hardening, notification preferences, appearance screen, and email invite removal. > Last updated: 2026-06-21 — reflects pairing security hardening, notification preferences, appearance screen, email invite removal, real PartnerHomeScreen, real SubscriptionScreen, ProGuard log stripping, strings.xml, and support URL fix. All 17 release blocker items resolved or pending deploy.
--- ---
@ -42,7 +42,8 @@
- [ ] Back navigation from auth screens returns to onboarding. - [ ] Back navigation from auth screens returns to onboarding.
### 1.5 Known onboarding gaps (from code scan) ### 1.5 Known onboarding gaps (from code scan)
- `AccountScreen` shows "Local profile" and disables "Sign in or create account" and "Export your data" rows. These need real wiring before public release.
- ~~`AccountScreen` shows "Local profile" and disables "Sign in or create account" and "Export your data" rows.~~ ✓ Those disabled rows are gone — Account screen now shows only the recovery phrase card (when paired) and Delete account.
- Home header text changes based on `partnerName` presence; confirm both states render. - Home header text changes based on `partnerName` presence; confirm both states render.
--- ---
@ -104,14 +105,20 @@
- [ ] Pull/refresh or retry on error works. - [ ] Pull/refresh or retry on error works.
### 3.2 Partner home (`PartnerHomeScreen`) ### 3.2 Partner home (`PartnerHomeScreen`)
- [ ] Renders `PlaceholderScreen` with correct copy.
- [ ] Actions navigate to invite flow or home. - [x] ~~Renders `PlaceholderScreen` with correct copy.~~ Screen is now a real dashboard (blocker #5 fixed).
- [ ] **Gap**: screen is a placeholder; not a functional partner dashboard yet. - [ ] Partner identity card shows avatar initial, name, and streak count.
- [ ] Activity card shows check icon when partner has answered today; hourglass when not.
- [ ] "Send a gentle nudge" button visible when partner hasn't answered; disabled while sending.
- [ ] Nudge success/error surfaces via snackbar.
- [ ] "View today's question" button navigates to `DAILY_QUESTION`.
- [ ] Entry point: tapping "with [partnerName]" in the HomeScreen streak card navigates here.
- [ ] Loading and error states render without crash.
### 3.3 Moment cue / special dates section ### 3.3 Moment cue / special dates section
- [ ] `SpecialDatesSection` previews render.
- [ ] Home moment cue card text not placeholder. - [x] ~~`SpecialDatesSection` previews render with hardcoded names.~~ `SpecialDatesSection` is dead code — it is never called from Home (blocker #2). No hardcoded names render.
- [ ] Hardcoded names ("Jessica", "Mark") and dates in `SpecialDatesSection` must be replaced with real data before public release. - [ ] Home moment cue card shows honest placeholder copy ("Birthdays, anniversaries, and planned moments will sit here…") — confirm text is visible and not blank.
--- ---
@ -235,8 +242,11 @@
- [ ] Tap navigates to detail/builder. - [ ] Tap navigates to detail/builder.
### 8.3 Date builder (`DateBuilderScreen`) ### 8.3 Date builder (`DateBuilderScreen`)
- [ ] Fields render: date, time, budget, duration.
- [ ] **Gap**: date and time fields show "TODO: Date picker dialog" / "TODO: Time picker dialog"; they are not interactive yet. - [x] ~~Date and time fields show "TODO" placeholders.~~ `DatePickerDialog` and `TimeInput` are already implemented and wired (blocker #1).
- [ ] Fields render: date picker, time input, budget, duration chips.
- [ ] Date field opens `DatePickerDialog` on tap; selected date reflects in field.
- [ ] Time field accepts input correctly.
- [ ] Budget input accepts digits only. - [ ] Budget input accepts digits only.
- [ ] Duration chips selectable. - [ ] Duration chips selectable.
- [ ] Save button calls view model. - [ ] Save button calls view model.
@ -262,14 +272,15 @@
- [ ] Tapping partner card opens `RELATIONSHIP_SETTINGS` (paired) or `CREATE_INVITE` (unpaired). - [ ] Tapping partner card opens `RELATIONSHIP_SETTINGS` (paired) or `CREATE_INVITE` (unpaired).
- [ ] **Appearance** row (palette icon) present and opens `APPEARANCE` screen. - [ ] **Appearance** row (palette icon) present and opens `APPEARANCE` screen.
- [ ] Notifications, Subscription, Privacy & Terms rows open correct screens. - [ ] Notifications, Subscription, Privacy & Terms rows open correct screens.
- [ ] No "Email Invite" or "Invite by Email" row present anywhere in settings. - [x] No "Email Invite" or "Invite by Email" row present anywhere in settings`EmailInviteScreen` deleted (blocker #3).
- [ ] Legal links open external URLs. - [ ] Legal links open external URLs.
- [ ] Delete account row opens `DELETE_ACCOUNT`. - [ ] Delete account row opens `DELETE_ACCOUNT`.
- [ ] Sign out button works and shows loading state. - [ ] Sign out button works and shows loading state.
### 9.2 Account (`AccountScreen`) ### 9.2 Account (`AccountScreen`)
- [ ] "Local profile" card shown for signed-out state. - [ ] "Local profile" card shown for signed-out state.
- [ ] Disabled rows visually greyed out. - [x] ~~Disabled rows ("Auth coming soon", "Export coming soon") visually greyed out.~~ Those rows no longer exist (blocker #6).
- [ ] **Recovery phrase card** shown when paired (key icon, monospaced phrase, copy button). - [ ] **Recovery phrase card** shown when paired (key icon, monospaced phrase, copy button).
- [ ] Copy button copies phrase to clipboard and shows "Recovery phrase copied" snackbar. - [ ] Copy button copies phrase to clipboard and shows "Recovery phrase copied" snackbar.
- [ ] Recovery phrase card absent when not paired (no phrase to show). - [ ] Recovery phrase card absent when not paired (no phrase to show).
@ -293,21 +304,23 @@
- [ ] Back navigation returns to Settings. - [ ] Back navigation returns to Settings.
- [ ] "Device default" follows system dark/light mode correctly. - [ ] "Device default" follows system dark/light mode correctly.
### 9.4 Privacy (`PrivacyScreen`) ### 9.5 Privacy (`PrivacyScreen`)
- [ ] External links open in browser. - [ ] External links open in browser.
- [ ] No browser available case handled via `ExternalLinks.openUrl` Toast fallback. - [ ] No browser available case handled via `ExternalLinks.openUrl` Toast fallback.
- [ ] Back navigation works. - [ ] Back navigation works.
- [ ] Support URL resolves correctly — now `https://closer.app/support`. - [x] Support URL resolves correctly — updated to `https://closer.app/support` (blocker #7).
### 9.5 Subscription (`SubscriptionScreen`) ### 9.6 Subscription (`SubscriptionScreen`)
- [x] ~~Placeholder screen routing to paywall.~~ Real `SubscriptionViewModel` built (blocker #4).
- [ ] **Free state**: star icon, "Unlock Premium" header, benefits list, Upgrade button navigates to `PAYWALL`, Restore link. - [ ] **Free state**: star icon, "Unlock Premium" header, benefits list, Upgrade button navigates to `PAYWALL`, Restore link.
- [ ] **Premium state**: "You're Premium" card, renewal date (when available), benefits list, "Manage subscription" opens Play Store, Restore link. - [ ] **Premium state**: "You're Premium" card, renewal date (when available), benefits list, "Manage subscription" opens Play Store, Restore link.
- [ ] Restore purchases shows snackbar on success; error surfaces via snackbar. - [ ] Restore purchases shows snackbar on success; error surfaces via snackbar.
- [ ] Reads entitlement reactively — upgrading mid-session reflects immediately without restart. - [ ] Reads entitlement reactively — upgrading mid-session reflects immediately without restart.
- [ ] **Note**: Requires real RevenueCat API key + active product to fully test both states. - [ ] **Note**: Requires real RevenueCat API key + active product to fully test both states.
### 9.6 Relationship settings / Delete account ### 9.7 Relationship settings / Delete account
- [ ] See pairing section for leave-couple flow. - [ ] See pairing section for leave-couple flow.
- [ ] Delete account confirmation dialog requires acknowledgment checkbox. - [ ] Delete account confirmation dialog requires acknowledgment checkbox.
- [ ] Delete action calls `AuthRepository.deleteAccount()` and navigates to onboarding. - [ ] Delete action calls `AuthRepository.deleteAccount()` and navigates to onboarding.
@ -387,6 +400,8 @@
These findings came from the static review and should be fixed before public or store release. Do **not** block internal MVP on these unless explicitly required. These findings came from the static review and should be fixed before public or store release. Do **not** block internal MVP on these unless explicitly required.
**Summary: 16 of 17 items closed. 1 item pending a deploy command (#17).**
| # | Area | Issue | Severity | Status | | # | Area | Issue | Severity | Status |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | **Not an issue**`DatePickerDialog` and `TimeInput` are already implemented and wired | | 1 | Date builder | Date/time picker TODOs — fields are not interactive | High | **Not an issue**`DatePickerDialog` and `TimeInput` are already implemented and wired |

View File

@ -49,16 +49,20 @@ const admin = __importStar(require("firebase-admin"));
* The recovery phrase is stored on the invite document by the inviter and returned * The recovery phrase is stored on the invite document by the inviter and returned
* directly the acceptor never needs to type it manually. * directly the acceptor never needs to type it manually.
* *
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase } * Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase }
* - encryptedRecoveryPhrase: the Argon2id+AES-GCM blob stored by the inviter. The acceptor
* decrypts it client-side using the invite code they entered. The server never sees
* the plaintext phrase.
* *
* Operations (all via Admin SDK, so Firestore rules are bypassed): * Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired. * 1. Verify caller is authenticated and not already paired.
* 2. Look up the invite document by code. * 2. Rate-limit accept attempts per UID.
* 3. Validate status == 'pending' and not expired. * 3. Look up the invite document by code.
* 4. Prevent self-acceptance. * 4. Validate status == 'pending' and not expired.
* 5. Create the couple document with the wrapped couple key from the invite. * 5. Prevent self-acceptance.
* 6. Update both user documents with the new coupleId. * 6. Create the couple document with the wrapped couple key from the invite.
* 7. Mark the invite as accepted. * 7. Update both user documents with the new coupleId.
* 8. Mark the invite as accepted and wipe the encrypted phrase from the doc.
*/ */
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user const ACCEPT_RATE_LIMIT_MAX = 10; // 10 attempts per hour per user
@ -110,7 +114,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
const wrappedCoupleKey = invite.wrappedCoupleKey; const wrappedCoupleKey = invite.wrappedCoupleKey;
const kdfSalt = invite.kdfSalt; const kdfSalt = invite.kdfSalt;
const kdfParams = invite.kdfParams; const kdfParams = invite.kdfParams;
const recoveryPhrase = invite.recoveryPhrase; const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase;
if (status !== 'pending') { if (status !== 'pending') {
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.'); throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.');
} }
@ -149,15 +153,14 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
}); });
batch.update(db.collection('users').doc(inviterUserId), { coupleId }); batch.update(db.collection('users').doc(inviterUserId), { coupleId });
batch.update(db.collection('users').doc(callerId), { coupleId }); batch.update(db.collection('users').doc(callerId), { coupleId });
// Wipe the recovery phrase from the invite doc on acceptance. // Wipe the encrypted phrase blob on acceptance — it has been returned to the acceptor
// It served its purpose (returned to the acceptor above); leaving key material // who will decrypt it client-side. No reason to keep it in Firestore after use.
// in a document that stays in Firestore for 24h is unnecessary exposure.
batch.update(inviteRef, { batch.update(inviteRef, {
status: 'accepted', status: 'accepted',
acceptedByUserId: callerId, acceptedByUserId: callerId,
acceptedAt: admin.firestore.FieldValue.serverTimestamp(), acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
coupleId, coupleId,
recoveryPhrase: admin.firestore.FieldValue.delete(), encryptedRecoveryPhrase: admin.firestore.FieldValue.delete(),
}); });
await batch.commit(); await batch.commit();
console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`); console.log(`[acceptInviteCallable] ${callerId} accepted invite ${code}; created couple ${coupleId}`);
@ -167,7 +170,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null, encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null,
}; };
}); });
//# sourceMappingURL=acceptInviteCallable.js.map //# sourceMappingURL=acceptInviteCallable.js.map

File diff suppressed because one or more lines are too long

View File

@ -41,15 +41,18 @@ const admin = __importStar(require("firebase-admin"));
* *
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create * Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
* invites directly. 6-character document IDs are enumerable, so a direct client * invites directly. 6-character document IDs are enumerable, so a direct client
* write would expose pending invites to scanning. This function generates a * write would expose pending invites to scanning.
* unique 6-character code server-side, stores the invite document, and returns
* only the code and expiry to the inviter.
* *
* Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string } * Request body: { code?: string, wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, encryptedRecoveryPhrase?: string }
* - code: client-supplied 6-character code (Android). The server validates uniqueness and
* returns an error if taken so the client can retry with a new code. If omitted (iOS),
* the server generates the code.
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF * - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
* - kdfSalt: base64 KDF salt * - kdfSalt: base64 KDF salt
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1) * - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
* - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor) * - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using
* the invite code as the KDF input. The server stores it opaquely and never sees the
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
* *
* When E2EE fields are omitted the function writes nulls; iOS MVP creates * When E2EE fields are omitted the function writes nulls; iOS MVP creates
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not * plaintext couples (encryptionVersion=0 on the resulting couple) and does not
@ -60,7 +63,7 @@ const admin = __importStar(require("firebase-admin"));
* Operations (all via Admin SDK, so Firestore rules are bypassed): * Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired. * 1. Verify caller is authenticated and not already paired.
* 2. Rate-limit the caller to 5 invite creations per rolling hour. * 2. Rate-limit the caller to 5 invite creations per rolling hour.
* 3. Generate a unique 6-character alphanumeric code via transaction. * 3. Use client-supplied code or generate one server-side; validate uniqueness via transaction.
* 4. Write the invite document with a 24-hour TTL. * 4. Write the invite document with a 24-hour TTL.
*/ */
const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
@ -104,10 +107,11 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
if (recentInvites.size >= RATE_LIMIT_MAX) { if (recentInvites.size >= RATE_LIMIT_MAX) {
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.'); throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.');
} }
const clientCode = data === null || data === void 0 ? void 0 : data.code;
const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey; const wrappedCoupleKey = data === null || data === void 0 ? void 0 : data.wrappedCoupleKey;
const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt; const kdfSalt = data === null || data === void 0 ? void 0 : data.kdfSalt;
const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams; const kdfParams = data === null || data === void 0 ? void 0 : data.kdfParams;
const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase; const encryptedRecoveryPhrase = data === null || data === void 0 ? void 0 : data.encryptedRecoveryPhrase;
// E2EE fields must be supplied together or omitted together. // E2EE fields must be supplied together or omitted together.
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]; const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams];
const suppliedE2ee = e2eeFields.filter((v) => v != null).length; const suppliedE2ee = e2eeFields.filter((v) => v != null).length;
@ -115,20 +119,20 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.'); throw new functions.https.HttpsError('invalid-argument', 'E2EE fields (wrappedCoupleKey, kdfSalt, kdfParams) must all be supplied together or omitted together.');
} }
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS); const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS);
// Race-safe unique code creation via transaction. We attempt a bounded number // Android supplies its own code (used as the KDF input for phrase encryption, so the server
// of times; each attempt verifies the candidate code is free before creating. // must use it as-is). iOS omits the code; the server generates one in that case.
const maxAttempts = 10; // Either way, validate uniqueness via transaction and return an error on collision so the
// client can retry with a fresh code.
let inviteRef = null; let inviteRef = null;
let code = null; let code = null;
for (let attempt = 0; attempt < maxAttempts; attempt++) { const candidates = clientCode ? [clientCode] : Array.from({ length: 10 }, generateCode);
const candidate = generateCode(); for (const candidate of candidates) {
const candidateRef = db.collection('invites').doc(candidate); const candidateRef = db.collection('invites').doc(candidate);
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const created = await db.runTransaction(async (tx) => { const created = await db.runTransaction(async (tx) => {
const snap = await tx.get(candidateRef); const snap = await tx.get(candidateRef);
if (snap.exists) { if (snap.exists)
return false; return false;
}
tx.set(candidateRef, { tx.set(candidateRef, {
code: candidate, code: candidate,
inviterUserId: callerId, inviterUserId: callerId,
@ -141,7 +145,7 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null,
kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null,
kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null,
recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null, encryptedRecoveryPhrase: encryptedRecoveryPhrase !== null && encryptedRecoveryPhrase !== void 0 ? encryptedRecoveryPhrase : null,
}); });
return true; return true;
}); });
@ -152,7 +156,8 @@ exports.createInviteCallable = functions.https.onCall(async (data, context) => {
} }
} }
if (!code || !inviteRef) { if (!code || !inviteRef) {
throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.'); // Client-supplied code collided; the Android client will retry with a new code.
throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.');
} }
// Write a server-side audit log entry for the inviter. This is not read by // Write a server-side audit log entry for the inviter. This is not read by
// clients and supports the rate-limit count as well as future abuse review. // clients and supports the rate-limit count as well as future abuse review.

View File

@ -1 +1 @@
{"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"} {"version":3,"file":"createInviteCallable.js","sourceRoot":"","sources":["../../src/couples/createInviteCallable.ts"],"names":[],"mappings":"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"}

View File

@ -14,16 +14,20 @@ import * as admin from 'firebase-admin'
* The recovery phrase is stored on the invite document by the inviter and returned * The recovery phrase is stored on the invite document by the inviter and returned
* directly the acceptor never needs to type it manually. * directly the acceptor never needs to type it manually.
* *
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase } * Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, encryptedRecoveryPhrase }
* - encryptedRecoveryPhrase: the Argon2id+AES-GCM blob stored by the inviter. The acceptor
* decrypts it client-side using the invite code they entered. The server never sees
* the plaintext phrase.
* *
* Operations (all via Admin SDK, so Firestore rules are bypassed): * Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired. * 1. Verify caller is authenticated and not already paired.
* 2. Look up the invite document by code. * 2. Rate-limit accept attempts per UID.
* 3. Validate status == 'pending' and not expired. * 3. Look up the invite document by code.
* 4. Prevent self-acceptance. * 4. Validate status == 'pending' and not expired.
* 5. Create the couple document with the wrapped couple key from the invite. * 5. Prevent self-acceptance.
* 6. Update both user documents with the new coupleId. * 6. Create the couple document with the wrapped couple key from the invite.
* 7. Mark the invite as accepted. * 7. Update both user documents with the new coupleId.
* 8. Mark the invite as accepted and wipe the encrypted phrase from the doc.
*/ */
const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour const ACCEPT_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000 // 1 hour
const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user const ACCEPT_RATE_LIMIT_MAX = 10 // 10 attempts per hour per user
@ -86,7 +90,7 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined const wrappedCoupleKey = invite.wrappedCoupleKey as string | undefined
const kdfSalt = invite.kdfSalt as string | undefined const kdfSalt = invite.kdfSalt as string | undefined
const kdfParams = invite.kdfParams as string | undefined const kdfParams = invite.kdfParams as string | undefined
const recoveryPhrase = invite.recoveryPhrase as string | undefined const encryptedRecoveryPhrase = invite.encryptedRecoveryPhrase as string | undefined
if (status !== 'pending') { if (status !== 'pending') {
throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.') throw new functions.https.HttpsError('failed-precondition', 'Invite has already been used.')
@ -135,15 +139,14 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
batch.update(db.collection('users').doc(inviterUserId), { coupleId }) batch.update(db.collection('users').doc(inviterUserId), { coupleId })
batch.update(db.collection('users').doc(callerId), { coupleId }) batch.update(db.collection('users').doc(callerId), { coupleId })
// Wipe the recovery phrase from the invite doc on acceptance. // Wipe the encrypted phrase blob on acceptance — it has been returned to the acceptor
// It served its purpose (returned to the acceptor above); leaving key material // who will decrypt it client-side. No reason to keep it in Firestore after use.
// in a document that stays in Firestore for 24h is unnecessary exposure.
batch.update(inviteRef, { batch.update(inviteRef, {
status: 'accepted', status: 'accepted',
acceptedByUserId: callerId, acceptedByUserId: callerId,
acceptedAt: admin.firestore.FieldValue.serverTimestamp(), acceptedAt: admin.firestore.FieldValue.serverTimestamp(),
coupleId, coupleId,
recoveryPhrase: admin.firestore.FieldValue.delete(), encryptedRecoveryPhrase: admin.firestore.FieldValue.delete(),
}) })
await batch.commit() await batch.commit()
@ -156,6 +159,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
wrappedCoupleKey: wrappedCoupleKey ?? null, wrappedCoupleKey: wrappedCoupleKey ?? null,
kdfSalt: kdfSalt ?? null, kdfSalt: kdfSalt ?? null,
kdfParams: kdfParams ?? null, kdfParams: kdfParams ?? null,
recoveryPhrase: recoveryPhrase ?? null, encryptedRecoveryPhrase: encryptedRecoveryPhrase ?? null,
} }
}) })

View File

@ -6,15 +6,18 @@ import * as admin from 'firebase-admin'
* *
* Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create * Issue #9 / review2.md Risk #1 fix: clients are no longer allowed to create
* invites directly. 6-character document IDs are enumerable, so a direct client * invites directly. 6-character document IDs are enumerable, so a direct client
* write would expose pending invites to scanning. This function generates a * write would expose pending invites to scanning.
* unique 6-character code server-side, stores the invite document, and returns
* only the code and expiry to the inviter.
* *
* Request body: { wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, recoveryPhrase?: string } * Request body: { code?: string, wrappedCoupleKey?: string, kdfSalt?: string, kdfParams?: string, encryptedRecoveryPhrase?: string }
* - code: client-supplied 6-character code (Android). The server validates uniqueness and
* returns an error if taken so the client can retry with a new code. If omitted (iOS),
* the server generates the code.
* - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF * - wrappedCoupleKey: base64-encoded couple key wrapped by the inviter's KDF
* - kdfSalt: base64 KDF salt * - kdfSalt: base64 KDF salt
* - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1) * - kdfParams: KDF parameter tag (e.g. argon2id;v=19;m=47104;t=3;p=1)
* - recoveryPhrase: recovery phrase for the invite (optional, stored for acceptor) * - encryptedRecoveryPhrase: Argon2id+AES-GCM blob produced by the Android client using
* the invite code as the KDF input. The server stores it opaquely and never sees the
* plaintext phrase. Omitted by iOS until iOS implements E2EE parity.
* *
* When E2EE fields are omitted the function writes nulls; iOS MVP creates * When E2EE fields are omitted the function writes nulls; iOS MVP creates
* plaintext couples (encryptionVersion=0 on the resulting couple) and does not * plaintext couples (encryptionVersion=0 on the resulting couple) and does not
@ -25,7 +28,7 @@ import * as admin from 'firebase-admin'
* Operations (all via Admin SDK, so Firestore rules are bypassed): * Operations (all via Admin SDK, so Firestore rules are bypassed):
* 1. Verify caller is authenticated and not already paired. * 1. Verify caller is authenticated and not already paired.
* 2. Rate-limit the caller to 5 invite creations per rolling hour. * 2. Rate-limit the caller to 5 invite creations per rolling hour.
* 3. Generate a unique 6-character alphanumeric code via transaction. * 3. Use client-supplied code or generate one server-side; validate uniqueness via transaction.
* 4. Write the invite document with a 24-hour TTL. * 4. Write the invite document with a 24-hour TTL.
*/ */
@ -77,10 +80,11 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.') throw new functions.https.HttpsError('resource-exhausted', 'Too many invites created. Try again later.')
} }
const clientCode = data?.code as string | undefined
const wrappedCoupleKey = data?.wrappedCoupleKey as string | undefined const wrappedCoupleKey = data?.wrappedCoupleKey as string | undefined
const kdfSalt = data?.kdfSalt as string | undefined const kdfSalt = data?.kdfSalt as string | undefined
const kdfParams = data?.kdfParams as string | undefined const kdfParams = data?.kdfParams as string | undefined
const recoveryPhrase = data?.recoveryPhrase as string | undefined const encryptedRecoveryPhrase = data?.encryptedRecoveryPhrase as string | undefined
// E2EE fields must be supplied together or omitted together. // E2EE fields must be supplied together or omitted together.
const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams] const e2eeFields = [wrappedCoupleKey, kdfSalt, kdfParams]
@ -94,22 +98,22 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS) const expiresAt = admin.firestore.Timestamp.fromMillis(now.toMillis() + INVITE_TTL_MS)
// Race-safe unique code creation via transaction. We attempt a bounded number // Android supplies its own code (used as the KDF input for phrase encryption, so the server
// of times; each attempt verifies the candidate code is free before creating. // must use it as-is). iOS omits the code; the server generates one in that case.
const maxAttempts = 10 // Either way, validate uniqueness via transaction and return an error on collision so the
// client can retry with a fresh code.
let inviteRef: admin.firestore.DocumentReference | null = null let inviteRef: admin.firestore.DocumentReference | null = null
let code: string | null = null let code: string | null = null
for (let attempt = 0; attempt < maxAttempts; attempt++) { const candidates = clientCode ? [clientCode] : Array.from({ length: 10 }, generateCode)
const candidate = generateCode()
for (const candidate of candidates) {
const candidateRef = db.collection('invites').doc(candidate) const candidateRef = db.collection('invites').doc(candidate)
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const created = await db.runTransaction(async (tx) => { const created = await db.runTransaction(async (tx) => {
const snap = await tx.get(candidateRef) const snap = await tx.get(candidateRef)
if (snap.exists) { if (snap.exists) return false
return false
}
tx.set(candidateRef, { tx.set(candidateRef, {
code: candidate, code: candidate,
inviterUserId: callerId, inviterUserId: callerId,
@ -122,7 +126,7 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
wrappedCoupleKey: wrappedCoupleKey ?? null, wrappedCoupleKey: wrappedCoupleKey ?? null,
kdfSalt: kdfSalt ?? null, kdfSalt: kdfSalt ?? null,
kdfParams: kdfParams ?? null, kdfParams: kdfParams ?? null,
recoveryPhrase: recoveryPhrase ?? null, encryptedRecoveryPhrase: encryptedRecoveryPhrase ?? null,
}) })
return true return true
}) })
@ -135,7 +139,8 @@ export const createInviteCallable = functions.https.onCall(async (data: any, con
} }
if (!code || !inviteRef) { if (!code || !inviteRef) {
throw new functions.https.HttpsError('internal', 'Could not generate a unique invite code. Please try again.') // Client-supplied code collided; the Android client will retry with a new code.
throw new functions.https.HttpsError('already-exists', 'Invite code is already taken. Please try again.')
} }
// Write a server-side audit log entry for the inviter. This is not read by // Write a server-side audit log entry for the inviter. This is not read by

View File

@ -56,13 +56,18 @@ struct EmptyStateView: View {
let icon: String let icon: String
let title: String let title: String
let message: String let message: String
var action: (title: String, handler: () -> Void)? var illustrationName: String? = nil
var action: (title: String, handler: () -> Void)? = nil
var body: some View { var body: some View {
VStack(spacing: CloserSpacing.lg) { VStack(spacing: CloserSpacing.lg) {
Image(systemName: icon) if let illustrationName {
.font(.system(size: 48)) CloserIllustrationView(imageName: illustrationName, size: 132)
.foregroundColor(.closerPrimary.opacity(0.6)) } else {
Image(systemName: icon)
.font(.system(size: 48))
.foregroundColor(.closerPrimary.opacity(0.6))
}
Text(title) Text(title)
.font(CloserFont.title3) .font(CloserFont.title3)
@ -84,6 +89,23 @@ struct EmptyStateView: View {
} }
} }
// MARK: - Illustration View
struct CloserIllustrationView: View {
let imageName: String
var size: CGFloat = 220
var cornerRadius: CGFloat = CloserRadius.xlarge
var body: some View {
Image(imageName)
.resizable()
.scaledToFill()
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.accessibilityHidden(true)
}
}
// MARK: - Partner Status Row // MARK: - Partner Status Row
struct PartnerStatusRow: View { struct PartnerStatusRow: View {
@ -287,4 +309,4 @@ struct PremiumGateView: View {
.closerShadow(level: .large) .closerShadow(level: .large)
.padding(CloserSpacing.xl) .padding(CloserSpacing.xl)
} }
} }

View File

@ -5,10 +5,23 @@ struct OnboardingView: View {
@Binding var showSignUp: Bool @Binding var showSignUp: Bool
@State private var currentPage = 0 @State private var currentPage = 0
let pages: [(icon: String, title: String, description: String)] = [ private let pages = [
("heart.fill", "Connect Deeper", "Daily questions, games, and shared experiences designed to bring you closer together."), OnboardingPage(
("lock.fill", "Private & Secure", "Your conversations are private. End-to-end encryption keeps your answers between you and your partner."), icon: "heart.fill",
("sparkles", "Grow Together", "Build stronger habits, discover new things, and celebrate your journey as a couple.") illustrationName: "illustration-couple-onboarding",
title: "Connect Deeper",
description: "Daily questions, games, and shared experiences designed to bring you closer together."
),
OnboardingPage(
icon: "lock.fill",
title: "Private & Secure",
description: "Your conversations are private. End-to-end encryption keeps your answers between you and your partner."
),
OnboardingPage(
icon: "sparkles",
title: "Grow Together",
description: "Build stronger habits, discover new things, and celebrate your journey as a couple."
)
] ]
var body: some View { var body: some View {
@ -30,15 +43,13 @@ struct OnboardingView: View {
TabView(selection: $currentPage) { TabView(selection: $currentPage) {
ForEach(pages.indices, id: \.self) { index in ForEach(pages.indices, id: \.self) { index in
OnboardingPageView( OnboardingPageView(
icon: pages[index].icon, page: pages[index]
title: pages[index].title,
description: pages[index].description
) )
.tag(index) .tag(index)
} }
} }
.tabViewStyle(.page(indexDisplayMode: .always)) .tabViewStyle(.page(indexDisplayMode: .always))
.frame(height: 260) .frame(height: 330)
Spacer() Spacer()
@ -67,22 +78,31 @@ struct OnboardingView: View {
} }
} }
struct OnboardingPageView: View { private struct OnboardingPage {
let icon: String let icon: String
var illustrationName: String? = nil
let title: String let title: String
let description: String let description: String
}
private struct OnboardingPageView: View {
let page: OnboardingPage
var body: some View { var body: some View {
VStack(spacing: CloserSpacing.xl) { VStack(spacing: CloserSpacing.lg) {
Image(systemName: icon) if let illustrationName = page.illustrationName {
.font(.system(size: 44)) CloserIllustrationView(imageName: illustrationName, size: 170)
.foregroundColor(.closerPrimary) } else {
Image(systemName: page.icon)
.font(.system(size: 44))
.foregroundColor(.closerPrimary)
}
Text(title) Text(page.title)
.font(CloserFont.title2) .font(CloserFont.title2)
.foregroundColor(.closerText) .foregroundColor(.closerText)
Text(description) Text(page.description)
.font(CloserFont.body) .font(CloserFont.body)
.foregroundColor(.closerTextSecondary) .foregroundColor(.closerTextSecondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -513,4 +533,4 @@ struct CreateProfileView: View {
isLoading = false isLoading = false
} }
} }
} }

View File

@ -11,9 +11,7 @@ struct PairPromptView: View {
VStack(spacing: CloserSpacing.xxl) { VStack(spacing: CloserSpacing.xxl) {
Spacer() Spacer()
Image(systemName: "link.circle.fill") CloserIllustrationView(imageName: "illustration-couple-invite", size: 190)
.font(.system(size: 72))
.foregroundColor(.closerPrimary)
Text("Connect with Your Partner") Text("Connect with Your Partner")
.font(CloserFont.title1) .font(CloserFont.title1)
@ -334,4 +332,4 @@ struct EncryptionUpgradeView: View {
.background(Color.closerBackground) .background(Color.closerBackground)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
} }
} }

View File

@ -321,7 +321,8 @@ struct AnswerHistoryView: View {
EmptyStateView( EmptyStateView(
icon: "clock.arrow.circlepath", icon: "clock.arrow.circlepath",
title: "No Answers Yet", title: "No Answers Yet",
message: "Your answer history will appear here." message: "Your answer history will appear here.",
illustrationName: "illustration-couple-history"
) )
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
} else { } else {
@ -573,4 +574,4 @@ let samplePacks: [QuestionPack] = [
QuestionPack(id: "fun", name: "Fun & Playful", description: "Lighthearted questions about playfulness, laughter, and shared activities.", categories: nil, isPremium: false), QuestionPack(id: "fun", name: "Fun & Playful", description: "Lighthearted questions about playfulness, laughter, and shared activities.", categories: nil, isPremium: false),
QuestionPack(id: "date_night", name: "Date Night", description: "Questions designed for dates, meals, walks, or quiet time together.", categories: nil, isPremium: true), QuestionPack(id: "date_night", name: "Date Night", description: "Questions designed for dates, meals, walks, or quiet time together.", categories: nil, isPremium: true),
QuestionPack(id: "trust", name: "Trust", description: "Questions about trust, repair, and rebuilding.", categories: nil, isPremium: true), QuestionPack(id: "trust", name: "Trust", description: "Questions about trust, repair, and rebuilding.", categories: nil, isPremium: true),
] ]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@ -135,25 +135,24 @@ final class FirestoreService: @unchecked Sendable {
// MARK: - Callable Functions // MARK: - Callable Functions
extension FirestoreService { extension FirestoreService {
func acceptInviteCallable(code: String, recoveryPhrase: String? = nil) async throws -> String { // TODO(iOS-E2EE): iOS does not yet generate E2EE keys or encrypt the recovery phrase,
var data: [String: Any] = ["code": code] // so iOS-originated invites create plaintext couples (encryptionVersion=0). Cross-platform
if let phrase = recoveryPhrase { // couples where the Android user invites must go through acceptInviteCallable on Android.
data["recoveryPhrase"] = phrase // When iOS implements E2EE parity (CryptoKit keyset + Argon2id phrase cipher), update
} // createInviteCallable to supply wrappedCoupleKey, kdfSalt, kdfParams, and
let result = try await functions.httpsCallable("acceptInviteCallable").call(data) // encryptedRecoveryPhrase, and update acceptInviteCallable to decrypt the phrase.
func acceptInviteCallable(code: String) async throws -> String {
let result = try await functions.httpsCallable("acceptInviteCallable").call(["code": code])
guard let coupleId = (result.data as? [String: Any])?["coupleId"] as? String else { guard let coupleId = (result.data as? [String: Any])?["coupleId"] as? String else {
throw FirestoreError.invalidResponse throw FirestoreError.invalidResponse
} }
return coupleId return coupleId
} }
func createInviteCallable(inviteCode: String? = nil) async throws -> (code: String, expiresAt: Date) { func createInviteCallable() async throws -> (code: String, expiresAt: Date) {
var data: [String: Any] = [:] // iOS MVP omits all E2EE fields; server writes nulls and sets encryptionVersion=0.
// iOS MVP skips E2EE; the server writes null for the wrapped couple key. let data: [String: Any] = [:]
// When iOS E2EE parity lands, pass wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase here.
if let code = inviteCode {
data["preferredCode"] = code
}
let result = try await functions.httpsCallable("createInviteCallable").call(data) let result = try await functions.httpsCallable("createInviteCallable").call(data)
guard let payload = result.data as? [String: Any], guard let payload = result.data as? [String: Any],
let code = payload["code"] as? String, let code = payload["code"] as? String,

View File

@ -32,7 +32,10 @@ let package = Package(
.product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"), .product(name: "GoogleSignIn", package: "GoogleSignIn-iOS"),
], ],
path: "Closer", path: "Closer",
exclude: ["Info.plist", "Closer.entitlements"] exclude: ["Info.plist", "Closer.entitlements"],
resources: [
.process("Resources")
]
), ),
.testTarget( .testTarget(
name: "CloserTests", name: "CloserTests",
@ -45,4 +48,4 @@ let package = Package(
path: "CloserUITests" path: "CloserUITests"
), ),
] ]
) )