diff --git a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt index b4f2fec7..4bf1cad2 100644 --- a/app/src/main/java/app/closer/crypto/SealedRevealManager.kt +++ b/app/src/main/java/app/closer/crypto/SealedRevealManager.kt @@ -200,7 +200,13 @@ class SealedRevealManager @Inject constructor( recipientUserId = userId ) ?: return null - val myPrivateKey = userKeyManager.loadPrivateKey() ?: return null + // Same escrow fallback as the daily path — and this is the one that matters most: question + // threads are the LIVE sealed surface (daily answers are couple-key/schemaVersion 2), so a + // second device loses thread reveals first. Healing only the daily path would have fixed the + // dead one and left the real one broken. + val myPrivateKey = userKeyManager.loadPrivateKey() + ?: restorePrivateKeyFromEscrow(userId, coupleId) + ?: return null val oneTimeKey = releaseKeyEncryptor.unwrapFromSender( keyboxB64 = keybox, recipientPrivateKey = myPrivateKey, diff --git a/app/src/test/java/app/closer/crypto/PartnerAnswerResultTest.kt b/app/src/test/java/app/closer/crypto/PartnerAnswerResultTest.kt index 140b5ff7..bc6969d6 100644 --- a/app/src/test/java/app/closer/crypto/PartnerAnswerResultTest.kt +++ b/app/src/test/java/app/closer/crypto/PartnerAnswerResultTest.kt @@ -83,4 +83,26 @@ class PartnerAnswerResultTest { assertEquals(SealedRevealManager.PartnerAnswerResult.KeyUnavailable, decrypt()) io.mockk.coVerify { crashReporter.recordException(any()) } } + + @Test + fun `the THREAD reveal also falls back to escrow — it is the live sealed surface`() = runTest { + // Threads are where sealed answers actually run today (daily answers are couple-key), so a + // second device loses THESE first. The escrow fallback originally went only on the daily + // path — healing the dead one and leaving the real one broken. + coEvery { releaseKeyDataSource.readReleaseKeyForThread(any(), any(), any(), any()) } returns "keybox:v1:x" + every { userKeyManager.loadPrivateKey() } returns null + coEvery { deviceKeyDataSource.getPrivateKeyEscrow("me") } returns null + + val result = manager.decryptPartnerThreadAnswer( + coupleId = "c1", + threadId = "t1", + partnerId = "partner", + userId = "me", + encryptedPayload = "sealed:v1:whatever" + ) + + // Null (not a crash), and the escrow was consulted rather than giving up on the local key. + assertEquals(null, result) + io.mockk.coVerify { deviceKeyDataSource.getPrivateKeyEscrow("me") } + } }