fix(crypto): escrow the THREAD reveal too — the live sealed surface

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.
This commit is contained in:
null 2026-07-15 23:35:31 -05:00
parent a216d1b20a
commit 2f16d1e09c
2 changed files with 29 additions and 1 deletions

View File

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

View File

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