From 4dad0e774e37900c465c987ed3b2b921d968d1ba Mon Sep 17 00:00:00 2001 From: null Date: Sat, 20 Jun 2026 18:09:46 -0500 Subject: [PATCH] refactor: update crypto, invite flow, and account screen patterns --- .../closer/crypto/CoupleEncryptionManager.kt | 6 +- .../java/app/closer/crypto/CoupleKeyStore.kt | 24 +++++- .../data/remote/FirestoreInviteDataSource.kt | 19 +++-- .../data/repository/InviteRepositoryImpl.kt | 6 +- .../domain/repository/InviteRepository.kt | 7 +- .../closer/ui/pairing/InviteConfirmScreen.kt | 38 +-------- .../ui/pairing/InviteConfirmViewModel.kt | 57 +++++++------- .../app/closer/ui/settings/AccountScreen.kt | 77 +++++++++++++++++++ .../ui/settings/EditProfileViewModel.kt | 14 +++- firestore.rules | 2 +- .../dist/couples/acceptInviteCallable.js | 18 ++--- .../dist/couples/acceptInviteCallable.js.map | 2 +- functions/src/couples/acceptInviteCallable.ts | 20 ++--- 13 files changed, 180 insertions(+), 110 deletions(-) diff --git a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt index 28039dbc..6c5bbd64 100644 --- a/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt +++ b/app/src/main/java/app/closer/crypto/CoupleEncryptionManager.kt @@ -49,13 +49,14 @@ class CoupleEncryptionManager @Inject constructor( val handle = keyManager.newCoupleKeyset() val wrapped = keyManager.wrap(handle, phrase) keyStore.storeInviteKeyset(inviteCode, handle) + keyStore.storeInvitePhrase(inviteCode, phrase) SetupResult(handle, wrapped, phrase) } /** * Called by the acceptor during pairing, and on any device during recovery. * Derives the wrap key from [phrase], unwraps the keyset, and stores it - * locally under [coupleId]. + * 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. */ suspend fun unwrapAndStore( @@ -66,9 +67,12 @@ class CoupleEncryptionManager @Inject constructor( runCatching { val handle = keyManager.unwrap(wrapped, phrase) 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. * Handles inviter reconciliation (flow B') transparently. diff --git a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt index 4b1b131d..65885891 100644 --- a/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt +++ b/app/src/main/java/app/closer/crypto/CoupleKeyStore.kt @@ -45,17 +45,34 @@ class CoupleKeyStore @Inject constructor( 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? = load(prefKey(coupleId)) fun loadInviteKeyset(inviteCode: String): KeysetHandle? = load(invitePrefKey(inviteCode)) - /** Moves the invite-slot keyset to the coupleId slot and removes the invite slot. */ + /** Moves the invite-slot keyset (and phrase) to the coupleId slot and removes the invite slots. */ fun reconcileInviteKeyset(inviteCode: String, coupleId: String): Boolean { val handle = loadInviteKeyset(inviteCode) ?: return false 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 } @@ -63,6 +80,7 @@ class CoupleKeyStore @Inject constructor( prefs.edit() .remove(prefKey(coupleId)) .remove(pendingPhraseKey(coupleId)) + .remove(recoveryPhraseKey(coupleId)) .apply() aeadCache.remove(coupleId) } @@ -88,6 +106,8 @@ class CoupleKeyStore @Inject constructor( private fun prefKey(coupleId: String) = "keyset_$coupleId" 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 serialize(handle: KeysetHandle): String { diff --git a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt index 82817a89..b61fb3a0 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreInviteDataSource.kt @@ -27,7 +27,8 @@ class FirestoreInviteDataSource @Inject constructor( suspend fun createInvite( code: String, inviterUserId: String, - wrappedKey: RecoveryKeyManager.WrappedKey + wrappedKey: RecoveryKeyManager.WrappedKey, + recoveryPhrase: String ): Unit = suspendCancellableCoroutine { cont -> val now = System.currentTimeMillis() inviteRef(code).set( @@ -39,7 +40,8 @@ class FirestoreInviteDataSource @Inject constructor( "expiresAt" to Timestamp(now / 1000 + 24 * 60 * 60, 0), "wrappedCoupleKey" to wrappedKey.cipherB64, "kdfSalt" to wrappedKey.saltB64, - "kdfParams" to wrappedKey.params + "kdfParams" to wrappedKey.params, + "recoveryPhrase" to recoveryPhrase ) ) .addOnSuccessListener { cont.resume(Unit) } @@ -79,14 +81,13 @@ class FirestoreInviteDataSource @Inject constructor( * Accepts an invite server-side via the [acceptInviteCallable] Cloud Function. * * The client no longer reads the invite document directly (issue #9 fix). - * Instead, the function validates the code, creates the couple, updates both - * user documents, and returns the inviter UID and wrapped key so the acceptor - * can decrypt the couple keyset locally. + * The function reads the recovery phrase from the invite doc and returns it, + * so the acceptor never needs to type it manually. */ - suspend fun acceptInvite(code: String, recoveryPhrase: String): app.closer.domain.repository.AcceptInviteResult { + suspend fun acceptInvite(code: String): app.closer.domain.repository.AcceptInviteResult { @Suppress("DEPRECATION") val result = functions.getHttpsCallable("acceptInviteCallable") - .call(mapOf("code" to code, "recoveryPhrase" to recoveryPhrase)) + .call(mapOf("code" to code)) .await() val data = result.getData() as? Map<*, *> ?: throw IllegalStateException("Invalid response from acceptInviteCallable") @@ -101,6 +102,7 @@ class FirestoreInviteDataSource @Inject constructor( ?: throw IllegalStateException("Missing kdfSalt in acceptInvite response") val kdfParams = data["kdfParams"] as? String ?: throw IllegalStateException("Missing kdfParams in acceptInvite response") + val recoveryPhrase = data["recoveryPhrase"] as? String return app.closer.domain.repository.AcceptInviteResult( coupleId = coupleId, @@ -109,7 +111,8 @@ class FirestoreInviteDataSource @Inject constructor( cipherB64 = wrappedCoupleKey, saltB64 = kdfSalt, params = kdfParams - ) + ), + recoveryPhrase = recoveryPhrase ) } diff --git a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt index 5fbb5494..69782247 100644 --- a/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/InviteRepositoryImpl.kt @@ -18,7 +18,7 @@ class InviteRepositoryImpl @Inject constructor( override suspend fun createInvite(inviterUserId: String): Result = runCatching { val code = dataSource.generateCode() 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) } @@ -26,7 +26,7 @@ class InviteRepositoryImpl @Inject constructor( dataSource.getInviteByCode(code) } - override suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result = runCatching { - dataSource.acceptInvite(code, recoveryPhrase) + override suspend fun acceptInvite(code: String, acceptorUserId: String): Result = runCatching { + dataSource.acceptInvite(code) } } diff --git a/app/src/main/java/app/closer/domain/repository/InviteRepository.kt b/app/src/main/java/app/closer/domain/repository/InviteRepository.kt index 8bf3c716..2d3cc19d 100644 --- a/app/src/main/java/app/closer/domain/repository/InviteRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/InviteRepository.kt @@ -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 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( val coupleId: String, val inviterUserId: String, - val wrappedKey: RecoveryKeyManager.WrappedKey + val wrappedKey: RecoveryKeyManager.WrappedKey, + val recoveryPhrase: String? ) interface InviteRepository { suspend fun createInvite(inviterUserId: String): Result suspend fun getInviteByCode(code: String): Result - suspend fun acceptInvite(code: String, acceptorUserId: String, recoveryPhrase: String): Result + suspend fun acceptInvite(code: String, acceptorUserId: String): Result } diff --git a/app/src/main/java/app/closer/ui/pairing/InviteConfirmScreen.kt b/app/src/main/java/app/closer/ui/pairing/InviteConfirmScreen.kt index 1045e960..bf2090a5 100644 --- a/app/src/main/java/app/closer/ui/pairing/InviteConfirmScreen.kt +++ b/app/src/main/java/app/closer/ui/pairing/InviteConfirmScreen.kt @@ -25,8 +25,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -43,8 +41,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -146,39 +142,7 @@ fun InviteConfirmScreen( color = SettingsMuted ) - Spacer(Modifier.height(24.dp)) - - // Recovery phrase input — only shown for encrypted invites - if (state.isEncryptedInvite) { - OutlinedTextField( - value = state.recoveryPhrase, - onValueChange = viewModel::onPhraseChanged, - modifier = Modifier.fillMaxWidth(), - label = { Text("Recovery phrase") }, - placeholder = { Text("word word word word word word") }, - supportingText = { - Text( - "Your partner sees this when they create the invite.", - style = MaterialTheme.typography.bodySmall, - color = SettingsMuted - ) - }, - singleLine = false, - minLines = 2, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = SettingsPrimaryDeep, - unfocusedBorderColor = SettingsMuted.copy(alpha = 0.4f), - focusedLabelColor = SettingsPrimaryDeep, - unfocusedLabelColor = SettingsMuted, - cursorColor = SettingsPrimaryDeep - ), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done) - ) - Spacer(Modifier.height(16.dp)) - } else { - Spacer(Modifier.height(8.dp)) - } + Spacer(Modifier.height(32.dp)) Button( onClick = viewModel::confirmPairing, diff --git a/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt b/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt index bb93a2ef..b390bc7c 100644 --- a/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt +++ b/app/src/main/java/app/closer/ui/pairing/InviteConfirmViewModel.kt @@ -20,11 +20,9 @@ import javax.inject.Inject data class InviteConfirmUiState( val isLoading: Boolean = true, val inviterName: String? = null, - val recoveryPhrase: String = "", val error: String? = null, val navigateTo: String? = null, - val isConfirming: Boolean = false, - val isEncryptedInvite: Boolean = false + val isConfirming: Boolean = false ) @HiltViewModel @@ -43,42 +41,33 @@ class InviteConfirmViewModel @Inject constructor( init { 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 { - it.copy( - isLoading = false, - inviterName = "your partner", - isEncryptedInvite = true - ) + it.copy(isLoading = false, inviterName = "your partner") } } } - fun onPhraseChanged(phrase: String) = _uiState.update { it.copy(recoveryPhrase = phrase, error = null) } - fun confirmPairing() { val acceptorId = authRepository.currentUserId ?: run { _uiState.update { it.copy(error = "Not signed in.") } return } - val phrase = _uiState.value.recoveryPhrase.trim() _uiState.update { it.copy(isConfirming = true, error = null) } viewModelScope.launch { - inviteRepository.acceptInvite(inviteCode, acceptorId, phrase) + inviteRepository.acceptInvite(inviteCode, acceptorId) .onSuccess { result -> - encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase) - .onSuccess { - val inviterName = runCatching { userRepository.getUser(result.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) } - } - .onFailure { e -> - _uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) } - } + val phrase = result.recoveryPhrase + if (phrase != null && result.wrappedKey.cipherB64.isNotBlank()) { + encryptionManager.unwrapAndStore(result.coupleId, result.wrappedKey, phrase) + .onSuccess { + navigateHome(result.inviterUserId) + } + .onFailure { e -> + _uiState.update { it.copy(isConfirming = false, error = recoveryErrorMessage(e)) } + } + } else { + navigateHome(result.inviterUserId) + } } .onFailure { 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 dismissError() = _uiState.update { it.copy(error = null) } private fun recoveryErrorMessage(e: Throwable): String = when { (e.message ?: "").contains("AEADBadTag", 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." } @@ -106,7 +108,6 @@ class InviteConfirmViewModel @Inject constructor( else if (msg.contains("already paired", ignoreCase = true)) "You are already paired." else "Couldn't complete pairing. Please try again." 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." else -> e.message ?: "Couldn't complete pairing. Please try again." } diff --git a/app/src/main/java/app/closer/ui/settings/AccountScreen.kt b/app/src/main/java/app/closer/ui/settings/AccountScreen.kt index e46e9881..da5037ea 100644 --- a/app/src/main/java/app/closer/ui/settings/AccountScreen.kt +++ b/app/src/main/java/app/closer/ui/settings/AccountScreen.kt @@ -19,7 +19,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Key import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -33,16 +35,23 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import app.closer.core.navigation.AppRoute +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -50,7 +59,10 @@ fun AccountScreen( onNavigate: (String) -> Unit = {}, viewModel: EditProfileViewModel = hiltViewModel() ) { + val state by viewModel.uiState.collectAsState() val snackbar = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() + val clipboard = LocalClipboardManager.current Scaffold( snackbarHost = { SnackbarHost(snackbar) }, @@ -90,6 +102,71 @@ fun AccountScreen( 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 Card( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt b/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt index e15317ca..d3c0d964 100644 --- a/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt +++ b/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt @@ -3,8 +3,10 @@ package app.closer.ui.settings import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.closer.crypto.CoupleEncryptionManager import app.closer.data.remote.FirebaseStorageDataSource import app.closer.domain.repository.AuthRepository +import app.closer.domain.repository.CoupleRepository import app.closer.domain.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -27,14 +29,17 @@ data class EditProfileUiState( val error: String? = null, val success: Boolean = false, val nameError: String? = null, - val sexError: String? = null + val sexError: String? = null, + val recoveryPhrase: String? = null ) @HiltViewModel class EditProfileViewModel @Inject constructor( private val authRepository: AuthRepository, private val userRepository: UserRepository, - private val storageDataSource: FirebaseStorageDataSource + private val storageDataSource: FirebaseStorageDataSource, + private val coupleRepository: CoupleRepository, + private val encryptionManager: CoupleEncryptionManager ) : ViewModel() { private val _uiState = MutableStateFlow(EditProfileUiState()) @@ -51,6 +56,8 @@ class EditProfileViewModel @Inject constructor( } viewModelScope.launch { val user = runCatching { userRepository.getUser(uid) }.getOrNull() + val coupleId = runCatching { coupleRepository.getCoupleForUser(uid) }.getOrNull()?.id + val phrase = coupleId?.let { encryptionManager.recoveryPhrase(it) } _uiState.update { it.copy( isLoading = false, @@ -59,7 +66,8 @@ class EditProfileViewModel @Inject constructor( photoUrl = user?.photoUrl ?: "", email = authRepository.currentUserEmail ?: user?.email ?: "", isAnonymous = authRepository.isAnonymous, - isGoogleAccount = authRepository.isGoogleAccount + isGoogleAccount = authRepository.isGoogleAccount, + recoveryPhrase = phrase ) } } diff --git a/firestore.rules b/firestore.rules index c56c80ab..383afb0c 100644 --- a/firestore.rules +++ b/firestore.rules @@ -260,7 +260,7 @@ service cloud.firestore { && request.resource.data.keys().hasAll(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt', 'wrappedCoupleKey', 'kdfSalt', 'kdfParams']) && request.resource.data.keys().hasOnly(['inviterUserId', 'code', 'status', 'createdAt', 'expiresAt', - 'wrappedCoupleKey', 'kdfSalt', 'kdfParams']); + 'wrappedCoupleKey', 'kdfSalt', 'kdfParams', 'recoveryPhrase']); // Update (accept): server-side / Cloud Function only. // Direct client updates to invites are denied. The Cloud Function uses the diff --git a/functions/dist/couples/acceptInviteCallable.js b/functions/dist/couples/acceptInviteCallable.js index c33b43ff..18e3c9c0 100644 --- a/functions/dist/couples/acceptInviteCallable.js +++ b/functions/dist/couples/acceptInviteCallable.js @@ -43,11 +43,13 @@ const admin = __importStar(require("firebase-admin")); * the 6-character document ID was enumerable. The invite is looked up by code * 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. - * - 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): * 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') { 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(); // Caller must not already be paired. 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 kdfSalt = invite.kdfSalt; const kdfParams = invite.kdfParams; + const recoveryPhrase = invite.recoveryPhrase; if (status !== 'pending') { 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) { 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 coupleRef = db.collection('couples').doc(coupleId); const batch = db.batch(); @@ -137,6 +132,7 @@ exports.acceptInviteCallable = functions.https.onCall(async (data, context) => { wrappedCoupleKey: wrappedCoupleKey !== null && wrappedCoupleKey !== void 0 ? wrappedCoupleKey : null, kdfSalt: kdfSalt !== null && kdfSalt !== void 0 ? kdfSalt : null, kdfParams: kdfParams !== null && kdfParams !== void 0 ? kdfParams : null, + recoveryPhrase: recoveryPhrase !== null && recoveryPhrase !== void 0 ? recoveryPhrase : null, }; }); //# sourceMappingURL=acceptInviteCallable.js.map \ No newline at end of file diff --git a/functions/dist/couples/acceptInviteCallable.js.map b/functions/dist/couples/acceptInviteCallable.js.map index ad6a62fd..0c27a516 100644 --- a/functions/dist/couples/acceptInviteCallable.js.map +++ b/functions/dist/couples/acceptInviteCallable.js.map @@ -1 +1 @@ -{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"acceptInviteCallable.js","sourceRoot":"","sources":["../../src/couples/acceptInviteCallable.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/functions/src/couples/acceptInviteCallable.ts b/functions/src/couples/acceptInviteCallable.ts index e6114016..bb4e8e58 100644 --- a/functions/src/couples/acceptInviteCallable.ts +++ b/functions/src/couples/acceptInviteCallable.ts @@ -8,11 +8,13 @@ import * as admin from 'firebase-admin' * the 6-character document ID was enumerable. The invite is looked up by code * 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. - * - 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): * 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.') } - 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() // 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 kdfSalt = invite.kdfSalt as string | undefined const kdfParams = invite.kdfParams as string | undefined + const recoveryPhrase = invite.recoveryPhrase as string | undefined if (status !== 'pending') { 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.') } - // 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 coupleRef = db.collection('couples').doc(coupleId) @@ -121,5 +114,6 @@ export const acceptInviteCallable = functions.https.onCall(async (data: any, con wrappedCoupleKey: wrappedCoupleKey ?? null, kdfSalt: kdfSalt ?? null, kdfParams: kdfParams ?? null, + recoveryPhrase: recoveryPhrase ?? null, } })