refactor: update crypto, invite flow, and account screen patterns

This commit is contained in:
null 2026-06-20 18:09:46 -05:00
parent 09a2480359
commit 4dad0e774e
13 changed files with 180 additions and 110 deletions

View File

@ -49,13 +49,14 @@ class CoupleEncryptionManager @Inject constructor(
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.storeInviteKeyset(inviteCode, handle)
keyStore.storeInvitePhrase(inviteCode, phrase)
SetupResult(handle, wrapped, phrase) SetupResult(handle, wrapped, phrase)
} }
/** /**
* 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
* locally under [coupleId]. * locally under [coupleId]. Also persists [phrase] so it can be shown in settings.
* Throws [com.google.crypto.tink.subtle.Validators]-wrapped exception if phrase is wrong. * Throws [com.google.crypto.tink.subtle.Validators]-wrapped exception if phrase is wrong.
*/ */
suspend fun unwrapAndStore( suspend fun unwrapAndStore(
@ -66,9 +67,12 @@ class CoupleEncryptionManager @Inject constructor(
runCatching { runCatching {
val handle = keyManager.unwrap(wrapped, phrase) val handle = keyManager.unwrap(wrapped, phrase)
keyStore.storeKeyset(coupleId, handle) keyStore.storeKeyset(coupleId, handle)
keyStore.storeRecoveryPhrase(coupleId, phrase)
} }
} }
fun recoveryPhrase(coupleId: String): String? = keyStore.loadRecoveryPhrase(coupleId)
/** /**
* Called on app launch / Home load after the couple doc is resolved. * Called on app launch / Home load after the couple doc is resolved.
* Handles inviter reconciliation (flow B') transparently. * Handles inviter reconciliation (flow B') transparently.

View File

@ -45,17 +45,34 @@ class CoupleKeyStore @Inject constructor(
prefs.edit().putString(invitePrefKey(inviteCode), json).apply() prefs.edit().putString(invitePrefKey(inviteCode), json).apply()
} }
fun storeInvitePhrase(inviteCode: String, phrase: String) {
prefs.edit().putString(invitePhrasePrefKey(inviteCode), phrase).apply()
}
fun storeRecoveryPhrase(coupleId: String, phrase: String) {
prefs.edit().putString(recoveryPhraseKey(coupleId), phrase).apply()
}
fun loadRecoveryPhrase(coupleId: String): String? =
prefs.getString(recoveryPhraseKey(coupleId), null)
fun loadKeyset(coupleId: String): KeysetHandle? = fun loadKeyset(coupleId: String): KeysetHandle? =
load(prefKey(coupleId)) load(prefKey(coupleId))
fun loadInviteKeyset(inviteCode: String): KeysetHandle? = fun loadInviteKeyset(inviteCode: String): KeysetHandle? =
load(invitePrefKey(inviteCode)) load(invitePrefKey(inviteCode))
/** Moves the invite-slot keyset to the coupleId slot and removes the invite slot. */ /** Moves the invite-slot keyset (and phrase) to the coupleId slot and removes the invite slots. */
fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean { fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean {
val handle = loadInviteKeyset(inviteCode) ?: return false val handle = loadInviteKeyset(inviteCode) ?: return false
storeKeyset(coupleId, handle) storeKeyset(coupleId, handle)
prefs.edit().remove(invitePrefKey(inviteCode)).apply() prefs.edit().remove(invitePrefKey(inviteCode)).also { editor ->
val phrase = prefs.getString(invitePhrasePrefKey(inviteCode), null)
if (phrase != null) {
editor.putString(recoveryPhraseKey(coupleId), phrase)
editor.remove(invitePhrasePrefKey(inviteCode))
}
}.apply()
return true return true
} }
@ -63,6 +80,7 @@ class CoupleKeyStore @Inject constructor(
prefs.edit() prefs.edit()
.remove(prefKey(coupleId)) .remove(prefKey(coupleId))
.remove(pendingPhraseKey(coupleId)) .remove(pendingPhraseKey(coupleId))
.remove(recoveryPhraseKey(coupleId))
.apply() .apply()
aeadCache.remove(coupleId) aeadCache.remove(coupleId)
} }
@ -88,6 +106,8 @@ class CoupleKeyStore @Inject constructor(
private fun prefKey(coupleId: String) = "keyset_$coupleId" private fun prefKey(coupleId: String) = "keyset_$coupleId"
private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode" private fun invitePrefKey(inviteCode: String) = "keyset_invite_$inviteCode"
private fun invitePhrasePrefKey(inviteCode: String) = "phrase_invite_$inviteCode"
private fun recoveryPhraseKey(coupleId: String) = "recovery_phrase_$coupleId"
private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId" private fun pendingPhraseKey(coupleId: String) = "pending_recovery_phrase_$coupleId"
private fun serialize(handle: KeysetHandle): String { private fun serialize(handle: KeysetHandle): String {

View File

@ -27,7 +27,8 @@ class FirestoreInviteDataSource @Inject constructor(
suspend fun createInvite( suspend fun createInvite(
code: String, code: String,
inviterUserId: String, inviterUserId: String,
wrappedKey: RecoveryKeyManager.WrappedKey wrappedKey: RecoveryKeyManager.WrappedKey,
recoveryPhrase: String
): Unit = suspendCancellableCoroutine { cont -> ): Unit = suspendCancellableCoroutine { cont ->
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
inviteRef(code).set( inviteRef(code).set(
@ -39,7 +40,8 @@ class FirestoreInviteDataSource @Inject constructor(
"expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0), "expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0),
"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
) )
) )
.addOnSuccessListener { cont.resume(Unit) } .addOnSuccessListener { cont.resume(Unit) }
@ -79,14 +81,13 @@ class FirestoreInviteDataSource @Inject constructor(
* Accepts an invite server-side via the [acceptInviteCallable] Cloud Function. * Accepts an invite server-side via the [acceptInviteCallable] Cloud Function.
* *
* The client no longer reads the invite document directly (issue #9 fix). * The client no longer reads the invite document directly (issue #9 fix).
* Instead, the function validates the code, creates the couple, updates both * The function reads the recovery phrase from the invite doc and returns it,
* user documents, and returns the inviter UID and wrapped key so the acceptor * so the acceptor never needs to type it manually.
* can decrypt the couple keyset locally.
*/ */
suspend fun acceptInvite(code: String, recoveryPhrase: String): app.closer.domain.repository.AcceptInviteResult { suspend fun acceptInvite(code: String): app.closer.domain.repository.AcceptInviteResult {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val result = functions.getHttpsCallable("acceptInviteCallable") val result = functions.getHttpsCallable("acceptInviteCallable")
.call(mapOf("code" to code, "recoveryPhrase" to recoveryPhrase)) .call(mapOf("code" to code))
.await() .await()
val data = result.getData() as? Map<*, *> val data = result.getData() as? Map<*, *>
?: throw IllegalStateException("Invalid response from acceptInviteCallable") ?: throw IllegalStateException("Invalid response from acceptInviteCallable")
@ -101,6 +102,7 @@ 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
return app.closer.domain.repository.AcceptInviteResult( return app.closer.domain.repository.AcceptInviteResult(
coupleId = coupleId, coupleId = coupleId,
@ -109,7 +111,8 @@ class FirestoreInviteDataSource @Inject constructor(
cipherB64 = wrappedCoupleKey, cipherB64 = wrappedCoupleKey,
saltB64 = kdfSalt, saltB64 = kdfSalt,
params = kdfParams params = kdfParams
) ),
recoveryPhrase = recoveryPhrase
) )
} }

View File

@ -18,7 +18,7 @@ class InviteRepositoryImpl @Inject constructor(
override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching { override suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> = runCatching {
val code = dataSource.generateCode() val code = dataSource.generateCode()
val setup = encryptionManager.setupForNewCouple(code) val setup = encryptionManager.setupForNewCouple(code)
dataSource.createInvite(code, inviterUserId, setup.wrapped) dataSource.createInvite(code, inviterUserId, setup.wrapped, setup.recoveryPhrase)
CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase) CreateInviteResult(code = code, recoveryPhrase = setup.recoveryPhrase)
} }
@ -26,7 +26,7 @@ class InviteRepositoryImpl @Inject constructor(
dataSource.getInviteByCode(code) dataSource.getInviteByCode(code)
} }
override suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult> = runCatching { override suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult> = runCatching {
dataSource.acceptInvite(code, recoveryPhrase) dataSource.acceptInvite(code)
} }
} }

View File

@ -10,15 +10,18 @@ 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
* never needs to type it manually.
*/ */
data class AcceptInviteResult( data class AcceptInviteResult(
val coupleId: String, val coupleId: String,
val inviterUserId: String, val inviterUserId: String,
val wrappedKey: RecoveryKeyManager.WrappedKey val wrappedKey: RecoveryKeyManager.WrappedKey,
val recoveryPhrase: String?
) )
interface InviteRepository { interface InviteRepository {
suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult> suspend fun createInvite(inviterUserId: String): Result<CreateInviteResult>
suspend fun getInviteByCode(code: String): Result<Invite?> suspend fun getInviteByCode(code: String): Result<Invite?>
suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result<AcceptInviteResult> suspend fun acceptInvite(code: String, acceptorUserId: String): Result<AcceptInviteResult>
} }

View File

@ -25,8 +25,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@ -43,8 +41,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -146,39 +142,7 @@ fun InviteConfirmScreen(
color = SettingsMuted color = SettingsMuted
) )
Spacer(Modifier.height(24.dp)) Spacer(Modifier.height(32.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( Button(
onClick = viewModel::confirmPairing, onClick = viewModel::confirmPairing,

View File

@ -20,11 +20,9 @@ import javax.inject.Inject
data class InviteConfirmUiState( data class InviteConfirmUiState(
val isLoading: Boolean = true, val isLoading: Boolean = true,
val inviterName: String? = null, val inviterName: String? = null,
val recoveryPhrase: String = "",
val error: String? = null, val error: String? = null,
val navigateTo: String? = null, val navigateTo: String? = null,
val isConfirming: Boolean = false, val isConfirming: Boolean = false
val isEncryptedInvite: Boolean = false
) )
@HiltViewModel @HiltViewModel
@ -43,42 +41,33 @@ class InviteConfirmViewModel @Inject constructor(
init { init {
viewModelScope.launch { viewModelScope.launch {
// Invite details are no longer readable client-side. The Cloud Function
// will perform server-side validation and acceptance when the user confirms.
// We show a generic loading state and proceed to confirmation; the inviter name
// is fetched after a successful acceptance if needed.
_uiState.update { _uiState.update {
it.copy( it.copy(isLoading = false, inviterName = "your partner")
isLoading = false,
inviterName = "your partner",
isEncryptedInvite = true
)
} }
} }
} }
fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) }
fun confirmPairing() { fun confirmPairing() {
val acceptorId = authRepository.currentUserId ?: run { val acceptorId = authRepository.currentUserId ?: run {
_uiState.update { it.copy(error = "Not signed in.") } _uiState.update { it.copy(error = "Not signed in.") }
return return
} }
val phrase = _uiState.value.recoveryPhrase.trim()
_uiState.update { it.copy(isConfirming = true, error = null) } _uiState.update { it.copy(isConfirming = true, error = null) }
viewModelScope.launch { viewModelScope.launch {
inviteRepository.acceptInvite(inviteCode, acceptorId, phrase) inviteRepository.acceptInvite(inviteCode, acceptorId)
.onSuccess { result -> .onSuccess { result ->
encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase) val phrase = result.recoveryPhrase
.onSuccess { if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) {
val inviterName = runCatching { userRepository.getUser(result.inviterUserId)?.displayName } encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase)
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) } .onSuccess {
.getOrNull() navigateHome(result.inviterUserId)
_uiState.update { it.copy(isConfirming = false, inviterName = inviterName ?: "your partner", navigateTo = AppRoute.HOME) } }
} .onFailure { e ->
.onFailure { e -> _uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) }
_uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) } }
} } else {
navigateHome(result.inviterUserId)
}
} }
.onFailure { e -> .onFailure { e ->
_uiState.update { it.copy(isConfirming = false, error = callableErrorMessage(e)) } _uiState.update { it.copy(isConfirming = false, error = callableErrorMessage(e)) }
@ -86,13 +75,26 @@ class InviteConfirmViewModel @Inject constructor(
} }
} }
private suspend fun navigateHome(inviterUserId: String) {
val inviterName = runCatching { userRepository.getUser(inviterUserId)?.displayName }
.onFailure { e -> Log.w(TAG, "Could not load inviter display name", e) }
.getOrNull()
_uiState.update {
it.copy(
isConfirming = false,
inviterName = inviterName ?: "your partner",
navigateTo = AppRoute.HOME
)
}
}
fun onNavigated() = _uiState.update { it.copy(navigateTo = null) } fun onNavigated() = _uiState.update { it.copy(navigateTo = null) }
fun dismissError() = _uiState.update { it.copy(error = null) } fun dismissError() = _uiState.update { it.copy(error = null) }
private fun recoveryErrorMessage(e: Throwable): String = when { private fun recoveryErrorMessage(e: Throwable): String = when {
(e.message ?: "").contains("AEADBadTag", ignoreCase = true) || (e.message ?: "").contains("AEADBadTag", ignoreCase = true) ||
(e.message ?: "").contains("decryption", ignoreCase = true) -> (e.message ?: "").contains("decryption", ignoreCase = true) ->
"That phrase doesn't match. Ask your partner to recheck it." "Couldn't unlock the couple key. Please try pairing again."
else -> e.message ?: "Couldn't unlock the couple key. Please try again." else -> e.message ?: "Couldn't unlock the couple key. Please try again."
} }
@ -106,7 +108,6 @@ class InviteConfirmViewModel @Inject constructor(
else if (msg.contains("already paired", ignoreCase = true)) "You are already paired." else if (msg.contains("already paired", ignoreCase = true)) "You are already paired."
else "Couldn't complete pairing. Please try again." else "Couldn't complete pairing. Please try again."
msg.contains("permission-denied", ignoreCase = true) -> "You cannot accept your own invite." msg.contains("permission-denied", ignoreCase = true) -> "You cannot accept your own invite."
msg.contains("invalid-argument", ignoreCase = true) -> "Enter the recovery phrase your partner shared with you."
msg.contains("unauthenticated", ignoreCase = true) -> "Not signed in." msg.contains("unauthenticated", ignoreCase = true) -> "Not signed in."
else -> e.message ?: "Couldn't complete pairing. Please try again." else -> e.message ?: "Couldn't complete pairing. Please try again."
} }

View File

@ -19,7 +19,9 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Key
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -33,16 +35,23 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import app.closer.core.navigation.AppRoute import app.closer.core.navigation.AppRoute
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -50,7 +59,10 @@ fun AccountScreen(
onNavigate: (String) -> Unit = {}, onNavigate: (String) -> Unit = {},
viewModel: EditProfileViewModel = hiltViewModel() viewModel: EditProfileViewModel = hiltViewModel()
) { ) {
val state by viewModel.uiState.collectAsState()
val snackbar = remember { SnackbarHostState() } val snackbar = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
Scaffold( Scaffold(
snackbarHost = { SnackbarHost(snackbar) }, snackbarHost = { SnackbarHost(snackbar) },
@ -90,6 +102,71 @@ fun AccountScreen(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Recovery phrase — only shown when the couple key is stored locally
val phrase = state.recoveryPhrase
if (phrase != null) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = SettingsCard)
) {
Column(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
Icons.Filled.Key,
contentDescription = null,
tint = SettingsMuted,
modifier = Modifier.size(20.dp)
)
Text(
text = "Recovery phrase",
style = MaterialTheme.typography.bodyLarge,
color = SettingsInk,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
clipboard.setText(AnnotatedString(phrase))
scope.launch { snackbar.showSnackbar("Recovery phrase copied") }
},
modifier = Modifier.size(36.dp)
) {
Icon(
Icons.Filled.ContentCopy,
contentDescription = "Copy phrase",
tint = SettingsMuted,
modifier = Modifier.size(18.dp)
)
}
}
Text(
text = phrase,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = SettingsInk,
modifier = Modifier
.fillMaxWidth()
.background(
color = SettingsSoft,
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 12.dp, vertical = 10.dp)
)
Text(
text = "Keep this safe. Either partner can use it to restore access on a new device.",
style = MaterialTheme.typography.bodySmall,
color = SettingsMuted
)
}
}
}
// Account lifecycle // Account lifecycle
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),

View File

@ -3,8 +3,10 @@ package app.closer.ui.settings
import android.net.Uri import android.net.Uri
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import app.closer.crypto.CoupleEncryptionManager
import app.closer.data.remote.FirebaseStorageDataSource import app.closer.data.remote.FirebaseStorageDataSource
import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.AuthRepository
import app.closer.domain.repository.CoupleRepository
import app.closer.domain.repository.UserRepository import app.closer.domain.repository.UserRepository
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -27,14 +29,17 @@ data class EditProfileUiState(
val error: String? = null, val error: String? = null,
val success: Boolean = false, val success: Boolean = false,
val nameError: String? = null, val nameError: String? = null,
val sexError: String? = null val sexError: String? = null,
val recoveryPhrase: String? = null
) )
@HiltViewModel @HiltViewModel
class EditProfileViewModel @Inject constructor( class EditProfileViewModel @Inject constructor(
private val authRepository: AuthRepository, private val authRepository: AuthRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val storageDataSource: FirebaseStorageDataSource private val storageDataSource: FirebaseStorageDataSource,
private val coupleRepository: CoupleRepository,
private val encryptionManager: CoupleEncryptionManager
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(EditProfileUiState()) private val _uiState = MutableStateFlow(EditProfileUiState())
@ -51,6 +56,8 @@ class EditProfileViewModel @Inject constructor(
} }
viewModelScope.launch { viewModelScope.launch {
val user = runCatching { userRepository.getUser(uid) }.getOrNull() val user = runCatching { userRepository.getUser(uid) }.getOrNull()
val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id
val phrase = coupleId?.let { encryptionManager.recoveryPhrase(it) }
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
@ -59,7 +66,8 @@ class EditProfileViewModel @Inject constructor(
photoUrl = user?.photoUrl ?: "", photoUrl = user?.photoUrl ?: "",
email = authRepository.currentUserEmail ?: user?.email ?: "", email = authRepository.currentUserEmail ?: user?.email ?: "",
isAnonymous = authRepository.isAnonymous, isAnonymous = authRepository.isAnonymous,
isGoogleAccount = authRepository.isGoogleAccount isGoogleAccount = authRepository.isGoogleAccount,
recoveryPhrase = phrase
) )
} }
} }

View File

@ -260,7 +260,7 @@ service cloud.firestore {
&& request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt', && request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']) 'wrappedCoupleKey', 'kdfSalt', 'kdfParams'])
&& request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt', && request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt',
'wrappedCoupleKey', 'kdfSalt', 'kdfParams']); 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'recoveryPhrase']);
// Update (accept): server-side / Cloud Function only. // Update (accept): server-side / Cloud Function only.
// Direct client updates to invites are denied. The Cloud Function uses the // Direct client updates to invites are denied. The Cloud Function uses the

View File

@ -43,11 +43,13 @@ const admin = __importStar(require("firebase-admin"));
* the 6-character document ID was enumerable. The invite is looked up by code * the 6-character document ID was enumerable. The invite is looked up by code
* server-side, validated, and accepted atomically here. * server-side, validated, and accepted atomically here.
* *
* Request body: { code: string, recoveryPhrase?: string } * Request body: { code: string }
* - code: the 6-character invite code the partner shared. * - code: the 6-character invite code the partner shared.
* - recoveryPhrase: required if the invite was created with a wrapped couple key.
* *
* Response: { coupleId: string } * The recovery phrase is stored on the invite document by the inviter and returned
* directly the acceptor never needs to type it manually.
*
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
* *
* 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.
@ -68,10 +70,6 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
if (!code || typeof code !== 'string') { if (!code || typeof code !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'code is required.'); throw new functions.https.HttpsError('invalid-argument', 'code is required.');
} }
const recoveryPhrase = data === null || data === void 0 ? void 0 : data.recoveryPhrase;
if (recoveryPhrase !== undefined && typeof recoveryPhrase !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase must be a string.');
}
const db = admin.firestore(); const db = admin.firestore();
// Caller must not already be paired. // Caller must not already be paired.
const callerDoc = await db.collection('users').doc(callerId).get(); const callerDoc = await db.collection('users').doc(callerId).get();
@ -90,6 +88,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;
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.');
} }
@ -103,10 +102,6 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => {
if (inviterUserId === callerId) { if (inviterUserId === callerId) {
throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.'); throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.');
} }
// Recovery phrase is required whenever the invite carries a wrapped key.
if (wrappedCoupleKey != null && (!recoveryPhrase || recoveryPhrase.length === 0)) {
throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase is required for this invite.');
}
const coupleId = db.collection('couples').doc().id; const coupleId = db.collection('couples').doc().id;
const coupleRef = db.collection('couples').doc(coupleId); const coupleRef = db.collection('couples').doc(coupleId);
const batch = db.batch(); const batch = db.batch();
@ -137,6 +132,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,
}; };
}); });
//# sourceMappingURL=acceptInviteCallable.js.map //# sourceMappingURL=acceptInviteCallable.js.map

View File

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

View File

@ -8,11 +8,13 @@ import * as admin from 'firebase-admin'
* the 6-character document ID was enumerable. The invite is looked up by code * the 6-character document ID was enumerable. The invite is looked up by code
* server-side, validated, and accepted atomically here. * server-side, validated, and accepted atomically here.
* *
* Request body: { code: string, recoveryPhrase?: string } * Request body: { code: string }
* - code: the 6-character invite code the partner shared. * - code: the 6-character invite code the partner shared.
* - recoveryPhrase: required if the invite was created with a wrapped couple key.
* *
* Response: { coupleId: string } * The recovery phrase is stored on the invite document by the inviter and returned
* directly the acceptor never needs to type it manually.
*
* Response: { coupleId, inviterUserId, wrappedCoupleKey, kdfSalt, kdfParams, recoveryPhrase }
* *
* 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.
@ -34,11 +36,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
throw new functions.https.HttpsError('invalid-argument', 'code is required.') throw new functions.https.HttpsError('invalid-argument', 'code is required.')
} }
const recoveryPhrase = data?.recoveryPhrase
if (recoveryPhrase !== undefined && typeof recoveryPhrase !== 'string') {
throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase must be a string.')
}
const db = admin.firestore() const db = admin.firestore()
// Caller must not already be paired. // Caller must not already be paired.
@ -61,6 +58,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
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.')
@ -79,11 +77,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con
throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.') throw new functions.https.HttpsError('permission-denied', 'Cannot accept your own invite.')
} }
// Recovery phrase is required whenever the invite carries a wrapped key.
if (wrappedCoupleKey != null && (!recoveryPhrase || recoveryPhrase.length === 0)) {
throw new functions.https.HttpsError('invalid-argument', 'recoveryPhrase is required for this invite.')
}
const coupleId = db.collection('couples').doc().id const coupleId = db.collection('couples').doc().id
const coupleRef = db.collection('couples').doc(coupleId) const coupleRef = db.collection('couples').doc(coupleId)
@ -121,5 +114,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,
} }
}) })