diff --git a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt index 630b8453..768832f0 100644 --- a/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt +++ b/app/src/main/java/app/closer/data/remote/FirestoreUserDataSource.kt @@ -1,7 +1,10 @@ package app.closer.data.remote import app.closer.core.notifications.TokenRegistrar.DeviceMetadata +import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.FieldEncryptor import app.closer.domain.model.User +import com.google.firebase.firestore.DocumentSnapshot import com.google.firebase.firestore.FirebaseFirestore import com.google.firebase.firestore.SetOptions import kotlinx.coroutines.channels.awaitClose @@ -14,22 +17,54 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @Singleton -class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirestore) { +class FirestoreUserDataSource @Inject constructor( + private val db: FirebaseFirestore, + private val encryptionManager: CoupleEncryptionManager, + private val fieldEncryptor: FieldEncryptor +) { private fun userRef(uid: String) = db.collection(FirestoreCollections.USERS).document(uid) - private fun snapshotToUser(id: String, data: com.google.firebase.firestore.DocumentSnapshot): User = - User( + // ── Profile-metadata encryption (couple-key, AAD=coupleId). Currently: `sex`. ────────────────── + // Profile fields transit through plaintext (set at onboarding before a couple key exists) and are + // migrated to ciphertext at pairing, so reads must TOLERATE plaintext — unlike FieldEncryptor's + // fail-closed default. Never surface raw ciphertext: show the real value, or LOCKED if the key is + // missing on this device. + private fun decryptProfileField(raw: String, coupleId: String?): String { + if (raw.isBlank() || !fieldEncryptor.isEncrypted(raw)) return raw // legacy/plaintext passthrough + val aead = coupleId?.let { encryptionManager.aeadFor(it) } + return fieldEncryptor.decrypt(raw, aead, coupleId ?: "") ?: FieldEncryptor.LOCKED_PLACEHOLDER + } + + /** Encrypt a profile value under the user's couple key when available; otherwise store plaintext. */ + private fun encryptProfileField(value: String, coupleId: String?): String { + if (value.isBlank()) return value + if (fieldEncryptor.isEncrypted(value)) return value // already ciphertext (idempotent) + val aead = coupleId?.let { encryptionManager.aeadFor(it) } ?: return value + return fieldEncryptor.encrypt(value, aead, coupleId!!) + } + + private suspend fun getSnapshot(uid: String): DocumentSnapshot? = + suspendCancellableCoroutine { cont -> + userRef(uid).get() + .addOnSuccessListener { cont.resume(it.takeIf { s -> s.exists() }) } + .addOnFailureListener { cont.resumeWithException(it) } + } + + private fun snapshotToUser(id: String, data: DocumentSnapshot): User { + val coupleId = data.getString("coupleId") + return User( id = id, email = data.getString("email") ?: "", displayName = data.getString("displayName") ?: "", photoUrl = data.getString("photoUrl") ?: "", - sex = data.getString("sex") ?: "", + sex = decryptProfileField(data.getString("sex") ?: "", coupleId), partnerId = data.getString("partnerId"), - coupleId = data.getString("coupleId"), + coupleId = coupleId, plan = data.getString("plan") ?: "free", createdAt = data.getLong("createdAt") ?: 0L, lastActiveAt = data.getLong("lastActiveAt") ?: 0L ) + } suspend fun getUser(uid: String): User? = suspendCancellableCoroutine { cont -> @@ -59,7 +94,7 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest "email" to user.email, "displayName" to user.displayName, "photoUrl" to user.photoUrl, - "sex" to user.sex, + "sex" to encryptProfileField(user.sex, user.coupleId), "partnerId" to user.partnerId, "coupleId" to user.coupleId, "plan" to user.plan, @@ -91,16 +126,59 @@ class FirestoreUserDataSource @Inject constructor(private val db: FirebaseFirest .addOnFailureListener { cont.resumeWithException(it) } } - suspend fun updateSex(uid: String, sex: String): Unit = - suspendCancellableCoroutine { cont -> - userRef(uid).set( - mapOf("sex" to sex, "lastActiveAt" to System.currentTimeMillis()), - SetOptions.merge() + suspend fun updateSex(uid: String, sex: String) { + // Never write the locked placeholder back as the value (would corrupt + then encrypt it). + require(sex != FieldEncryptor.LOCKED_PLACEHOLDER) { "Refusing to persist the locked placeholder as sex" } + val coupleId = getSnapshot(uid)?.getString("coupleId") + setMerge( + uid, + mapOf( + "sex" to encryptProfileField(sex, coupleId), + "lastActiveAt" to System.currentTimeMillis() ) + ) + } + + private suspend fun setMerge(uid: String, data: Map): Unit = + suspendCancellableCoroutine { cont -> + userRef(uid).set(data, SetOptions.merge()) .addOnSuccessListener { cont.resume(Unit) } .addOnFailureListener { cont.resumeWithException(it) } } + /** + * Encrypt any still-plaintext profile fields once the couple key is available (idempotent — skips + * fields already `enc:v1:`). Targeted write: does NOT bump `lastActiveAt`. Safe to call on every + * key-unlock; heals pre-pairing-plaintext and legacy users. + */ + suspend fun migrateProfileFields(uid: String) { + val snap = getSnapshot(uid) ?: return + val coupleId = snap.getString("coupleId") ?: return + if (encryptionManager.aeadFor(coupleId) == null) return + val updates = mutableMapOf() + val rawSex = snap.getString("sex") ?: "" + if (rawSex.isNotBlank() && !fieldEncryptor.isEncrypted(rawSex)) { + updates["sex"] = encryptProfileField(rawSex, coupleId) + } + if (updates.isNotEmpty()) setMerge(uid, updates) + } + + /** + * Decrypt profile fields back to plaintext BEFORE the couple key/coupleId are torn down on unpair — + * otherwise the user could never read their own (couple-key + AAD-bound) fields again. Must run + * while the key still exists. No-op for fields that can't be decrypted here. + */ + suspend fun revertProfileFieldsToPlaintext(uid: String, coupleId: String) { + val aead = encryptionManager.aeadFor(coupleId) ?: return + val snap = getSnapshot(uid) ?: return + val updates = mutableMapOf() + val rawSex = snap.getString("sex") ?: "" + if (fieldEncryptor.isEncrypted(rawSex)) { + fieldEncryptor.decrypt(rawSex, aead, coupleId)?.let { updates["sex"] = it } + } + if (updates.isNotEmpty()) setMerge(uid, updates) + } + suspend fun hasProfile(uid: String): Boolean = suspendCancellableCoroutine { cont -> userRef(uid).get() diff --git a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt index 967331db..a8b9d668 100644 --- a/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/CoupleRepositoryImpl.kt @@ -43,6 +43,13 @@ class CoupleRepositoryImpl @Inject constructor( override suspend fun leaveCouple(userId: String): Result = runCatching { val coupleId = userDataSource.getUser(userId)?.coupleId + // Decrypt the couple-key-encrypted profile metadata back to plaintext BEFORE the key is torn + // down — otherwise these AAD(coupleId)-bound fields become permanently unreadable on unpair. + // Best-effort: never block the unpair on it. + if (coupleId != null) { + runCatching { userDataSource.revertProfileFieldsToPlaintext(userId, coupleId) } + .onFailure { crashReporter.recordException(it) } + } coupleDataSource.leaveCouple() if (coupleId != null) encryptionManager.deleteKeyset(coupleId) } diff --git a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt index f6286671..f3de9b30 100644 --- a/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt +++ b/app/src/main/java/app/closer/data/repository/UserRepositoryImpl.kt @@ -60,5 +60,7 @@ class UserRepositoryImpl @Inject constructor( override suspend fun clearCoupleId(uid: String) = dataSource.clearCoupleId(uid) + override suspend fun migrateProfileFields(uid: String) = dataSource.migrateProfileFields(uid) + override suspend fun deleteUserData(uid: String) = dataSource.deleteUserData(uid) } diff --git a/app/src/main/java/app/closer/domain/repository/UserRepository.kt b/app/src/main/java/app/closer/domain/repository/UserRepository.kt index f4f3f364..d20ea4b1 100644 --- a/app/src/main/java/app/closer/domain/repository/UserRepository.kt +++ b/app/src/main/java/app/closer/domain/repository/UserRepository.kt @@ -24,5 +24,7 @@ interface UserRepository { ) suspend fun updateQuietHours(uid: String, enabled: Boolean, startMinutes: Int, endMinutes: Int, timezone: String) suspend fun clearCoupleId(uid: String) + /** Encrypt any still-plaintext profile metadata once the couple key is available (idempotent). */ + suspend fun migrateProfileFields(uid: String) suspend fun deleteUserData(uid: String) } diff --git a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt index 4899b1af..6fabc6b1 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -247,6 +247,15 @@ class HomeViewModel @Inject constructor( val encryptionStatus = couple?.let(encryptionManager::checkStatus) val needsRecovery = encryptionStatus == EncryptionStatus.NEEDS_RECOVERY + // Heal profile-metadata encryption once the couple key is available (idempotent + targeted; + // covers pre-pairing-plaintext + legacy users). Skipped when the key isn't present yet. + if (uid != null && encryptionStatus != null && !needsRecovery) { + launch { + runCatching { userRepository.migrateProfileFields(uid) } + .onFailure { Log.w(TAG, "profile encryption migration failed", it) } + } + } + // Outcome check-in due-state calculation val appSettings = settingsRepository.settings.first() val outcomeBaselineShownAt = appSettings.outcomeBaselineShownAt 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 7ace9e88..3e9702d3 100644 --- a/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt +++ b/app/src/main/java/app/closer/ui/settings/EditProfileViewModel.kt @@ -4,6 +4,7 @@ import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.closer.crypto.CoupleEncryptionManager +import app.closer.crypto.FieldEncryptor import app.closer.data.remote.FirebaseStorageDataSource import app.closer.domain.repository.AuthRepository import app.closer.domain.repository.CoupleRepository @@ -61,8 +62,10 @@ class EditProfileViewModel @Inject constructor( _uiState.update { it.copy( isLoading = false, - displayName = user?.displayName ?: "", - sex = user?.sex ?: "", + // A field that can't be decrypted on this device reads back as the locked placeholder; + // treat it as unset so it's never re-saved (and re-encrypted) as the value. + displayName = (user?.displayName ?: "").stripLockedPlaceholder(), + sex = (user?.sex ?: "").stripLockedPlaceholder(), photoUrl = user?.photoUrl ?: "", email = authRepository.currentUserEmail ?: user?.email ?: "", isAnonymous = authRepository.isAnonymous, @@ -125,3 +128,7 @@ class EditProfileViewModel @Inject constructor( fun dismissError() = _uiState.update { it.copy(error = null) } fun onSuccessHandled() = _uiState.update { it.copy(success = false) } } + +/** Blank out the "couldn't unlock" placeholder so it never sits in editable form state. */ +private fun String.stripLockedPlaceholder(): String = + if (this == FieldEncryptor.LOCKED_PLACEHOLDER) "" else this