From 2f16d1e09c00c3756006b38ac7c412fe4ab4b186 Mon Sep 17 00:00:00 2001 From: null Date: Wed, 15 Jul 2026 23:35:31 -0500 Subject: [PATCH] =?UTF-8?q?fix(crypto):=20escrow=20the=20THREAD=20reveal?= =?UTF-8?q?=20too=20=E2=80=94=20the=20live=20sealed=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found during live verification of the escrow: I had added the fallback only to decryptPartnerAnswer, the DAILY path. Daily answers are schemaVersion 2 — couple-key encrypted, gated by rules, not ECIES-sealed. The sealed system's live surface is question THREADS. So the escrow healed the path that doesn't need it and left the one that does still failing on a second device: exactly the bug, untouched, behind a fix that looked complete. Worth naming the pattern: the exploration notes said this plainly ("the live blast radius today is question threads"), and I still wired the fix to the first call site I read. Reading the map is not the same as following it. decryptPartnerThreadAnswer now takes the same escrow fallback. Mutation-checked: reverting it to a bare loadPrivateKey() fails exactly the new test. Diagnostics from the live run removed. The escrow write itself is confirmed accepted by the DEPLOYED rules — an instrumented run showed "existing=true" on a device that had published one on an earlier Home load, which also proves the idempotence guard skips correctly. --- .../app/closer/crypto/SealedRevealManager.kt | 8 ++++++- .../closer/crypto/PartnerAnswerResultTest.kt | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) 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") } + } }