From 8b0bc582311779dd3a433330b287c70252c73dd9 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 15 Jul 2026 03:28:24 -0500 Subject: [PATCH] fix(crypto): adopt a key rotation at app start, not only on Home load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by live-verifying the rotation on a throwaway couple: after the partner rotated, the other device cold-started into its last screen — a conversation — and every message written under the new key rendered "🔒 Couldn't unlock on this device". It stayed that way until the user happened to tap Home, because that's the only place adoption ran. A chat notification opens a conversation directly, so this is the normal path, not a corner case: the partner's phone would look broken for as long as they avoided Home. The couple key is an app-level fact, so adopting it is app-level work. New CoupleKeyRotationAdopter runs on the startup path beside FCM registration (same authState collect + best-effort runCatching shape as registerFcmToken), and Home now calls the same use case instead of its own inline copy — one home for the logic, with an entry point for callers that already hold the couple (no redundant read) and one that resolves it. Failures go to CrashReporter per the house pattern; never throws, because a device that can't adopt right now is not broken — all pre-rotation content still decrypts and every later launch retries. Verified live on the throwaway couple, two rotations deep (generations 0→1→2): the partner cold-starts and reads all four messages across all three key eras, zero locked placeholders. The proof the startup path is what's doing it: Home logs only when IT adopts, that log never fired, and the content still decrypted — Home found the key already current because MainActivity had adopted first. Android suite green, assembleDebug clean. Co-Authored-By: Claude Opus 4.8 --- app/src/main/java/app/closer/MainActivity.kt | 20 ++++++++ .../usecase/CoupleKeyRotationAdopter.kt | 47 +++++++++++++++++++ .../java/app/closer/ui/home/HomeViewModel.kt | 3 +- 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/closer/domain/usecase/CoupleKeyRotationAdopter.kt diff --git a/app/src/main/java/app/closer/MainActivity.kt b/app/src/main/java/app/closer/MainActivity.kt index 8df9ff4e..b7525ad6 100644 --- a/app/src/main/java/app/closer/MainActivity.kt +++ b/app/src/main/java/app/closer/MainActivity.kt @@ -73,6 +73,7 @@ class MainActivity : AppCompatActivity() { @Inject lateinit var tokenRegistrar: TokenRegistrar @Inject lateinit var retentionAnalytics: app.closer.analytics.RetentionAnalytics @Inject lateinit var pendingJoinCodeStore: app.closer.data.local.PendingJoinCodeStore + @Inject lateinit var coupleKeyRotationAdopter: app.closer.domain.usecase.CoupleKeyRotationAdopter private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { } @@ -109,6 +110,7 @@ class MainActivity : AppCompatActivity() { } maybeRequestNotificationPermission() registerFcmToken() + adoptCoupleKeyRotation() pendingDeepLink.value = deepLinkRouteFromIntent(intent) if (BuildConfig.DEBUG) attemptDebugAutoLogin() // Drive the REAL Configuration uiMode from the in-app theme (Settings → Appearance) so that @@ -281,6 +283,24 @@ class MainActivity : AppCompatActivity() { } } + /** + * Adopt a couple-key rotation the partner performed, before any screen needs the key. Home does + * this too, but people don't always land on Home — a chat notification opens the conversation and + * a cold start restores the last screen, both of which would render new-key content as locked + * until Home happened to load. Best-effort, mirrors [registerFcmToken]. + */ + private fun adoptCoupleKeyRotation() { + lifecycleScope.launch { + authRepository.authState + .filterIsInstance() + .map { it.userId } + .distinctUntilChanged() + .collect { uid -> + runCatching { coupleKeyRotationAdopter.adoptIfNeeded(uid) } + } + } + } + private fun maybeRequestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) diff --git a/app/src/main/java/app/closer/domain/usecase/CoupleKeyRotationAdopter.kt b/app/src/main/java/app/closer/domain/usecase/CoupleKeyRotationAdopter.kt new file mode 100644 index 00000000..19fa3616 --- /dev/null +++ b/app/src/main/java/app/closer/domain/usecase/CoupleKeyRotationAdopter.kt @@ -0,0 +1,47 @@ +package app.closer.domain.usecase + +import app.closer.core.crash.CrashReporter +import app.closer.crypto.CoupleEncryptionManager +import app.closer.domain.repository.CoupleRepository +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Picks up a couple-key rotation performed on the partner's device, as early as possible after + * sign-in. + * + * Home's own load does this too, but Home is not guaranteed to be where people land: a chat + * notification opens the conversation directly, and the app restores its last screen on cold start. + * Caught live — a partner reopened into a thread after a rotation and every message written under the + * new key rendered "🔒 Couldn't unlock on this device" until they happened to tap Home. The key is + * an app-level fact, so adopting it is app-level work, not Home's. + * + * Best-effort and never throws: it runs on the startup path beside FCM registration, and a device + * that can't adopt right now is not broken — all pre-rotation content still decrypts, and Home's + * hook plus the next launch both try again. + */ +@Singleton +class CoupleKeyRotationAdopter @Inject constructor( + private val coupleRepository: CoupleRepository, + private val encryptionManager: CoupleEncryptionManager, + private val crashReporter: CrashReporter +) { + /** Startup entry point: resolves the couple itself. Returns the outcome for callers that care. */ + suspend fun adoptIfNeeded(uid: String): CoupleEncryptionManager.AdoptionResult { + val couple = runCatching { coupleRepository.getCoupleForUser(uid) } + .onFailure { crashReporter.recordException(it) } + .getOrNull() + ?: return CoupleEncryptionManager.AdoptionResult.UpToDate + return adoptForCouple(couple) + } + + /** For callers that already hold the couple (Home) — no redundant read. */ + suspend fun adoptForCouple(couple: app.closer.domain.model.Couple): CoupleEncryptionManager.AdoptionResult = + when (val result = encryptionManager.adoptRotationIfNeeded(couple)) { + is CoupleEncryptionManager.AdoptionResult.Failed -> { + crashReporter.recordException(result.cause) + result + } + else -> result + } +} 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 2424facc..463a3d2d 100644 --- a/app/src/main/java/app/closer/ui/home/HomeViewModel.kt +++ b/app/src/main/java/app/closer/ui/home/HomeViewModel.kt @@ -56,6 +56,7 @@ class HomeViewModel @Inject constructor( private val coupleRepository: CoupleRepository, private val userRepository: UserRepository, private val encryptionManager: CoupleEncryptionManager, + private val coupleKeyRotationAdopter: app.closer.domain.usecase.CoupleKeyRotationAdopter, private val db: FirebaseFirestore, private val functions: FirebaseFunctions, private val questionSessionRepository: QuestionSessionRepository, @@ -166,7 +167,7 @@ class HomeViewModel @Inject constructor( // difference between "it just works" and a screen full of 🔒. Old content is safe // either way — the local keyset is only ever replaced on success. if (couple != null && encryptionStatus == EncryptionStatus.UNLOCKED) { - when (val adoption = encryptionManager.adoptRotationIfNeeded(couple)) { + when (val adoption = coupleKeyRotationAdopter.adoptForCouple(couple)) { is CoupleEncryptionManager.AdoptionResult.Adopted -> Log.i(TAG, "Adopted rotated couple key (generation ${couple.keyGeneration})") is CoupleEncryptionManager.AdoptionResult.PhraseMissing -> {