fix(crypto): adopt a key rotation at app start, not only on Home load

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 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-15 03:28:24 -05:00
parent 33ac2f336d
commit 8b0bc58231
3 changed files with 69 additions and 1 deletions

View File

@ -73,6 +73,7 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var tokenRegistrar: TokenRegistrar @Inject lateinit var tokenRegistrar: TokenRegistrar
@Inject lateinit var retentionAnalytics: app.closer.analytics.RetentionAnalytics @Inject lateinit var retentionAnalytics: app.closer.analytics.RetentionAnalytics
@Inject lateinit var pendingJoinCodeStore: app.closer.data.local.PendingJoinCodeStore @Inject lateinit var pendingJoinCodeStore: app.closer.data.local.PendingJoinCodeStore
@Inject lateinit var coupleKeyRotationAdopter: app.closer.domain.usecase.CoupleKeyRotationAdopter
private val notificationPermissionLauncher = private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { } registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
@ -109,6 +110,7 @@ class MainActivity : AppCompatActivity() {
} }
maybeRequestNotificationPermission() maybeRequestNotificationPermission()
registerFcmToken() registerFcmToken()
adoptCoupleKeyRotation()
pendingDeepLink.value = deepLinkRouteFromIntent(intent) pendingDeepLink.value = deepLinkRouteFromIntent(intent)
if (BuildConfig.DEBUG) attemptDebugAutoLogin() if (BuildConfig.DEBUG) attemptDebugAutoLogin()
// Drive the REAL Configuration uiMode from the in-app theme (Settings → Appearance) so that // 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<AuthState.Authenticated>()
.map { it.userId }
.distinctUntilChanged()
.collect { uid ->
runCatching { coupleKeyRotationAdopter.adoptIfNeeded(uid) }
}
}
}
private fun maybeRequestNotificationPermission() { private fun maybeRequestNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)

View File

@ -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
}
}

View File

@ -56,6 +56,7 @@ class HomeViewModel @Inject constructor(
private val coupleRepository: CoupleRepository, private val coupleRepository: CoupleRepository,
private val userRepository: UserRepository, private val userRepository: UserRepository,
private val encryptionManager: CoupleEncryptionManager, private val encryptionManager: CoupleEncryptionManager,
private val coupleKeyRotationAdopter: app.closer.domain.usecase.CoupleKeyRotationAdopter,
private val db: FirebaseFirestore, private val db: FirebaseFirestore,
private val functions: FirebaseFunctions, private val functions: FirebaseFunctions,
private val questionSessionRepository: QuestionSessionRepository, 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 // 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. // either way — the local keyset is only ever replaced on success.
if (couple != null && encryptionStatus == EncryptionStatus.UNLOCKED) { if (couple != null && encryptionStatus == EncryptionStatus.UNLOCKED) {
when (val adoption = encryptionManager.adoptRotationIfNeeded(couple)) { when (val adoption = coupleKeyRotationAdopter.adoptForCouple(couple)) {
is CoupleEncryptionManager.AdoptionResult.Adopted -> is CoupleEncryptionManager.AdoptionResult.Adopted ->
Log.i(TAG, "Adopted rotated couple key (generation ${couple.keyGeneration})") Log.i(TAG, "Adopted rotated couple key (generation ${couple.keyGeneration})")
is CoupleEncryptionManager.AdoptionResult.PhraseMissing -> { is CoupleEncryptionManager.AdoptionResult.PhraseMissing -> {