c6c7bcb3aa5d

fix: Fail oversized duress before snapshot send (v2.9.10)

time
2026-07-30 23:17 UTC · 2 months ago
machine
koh2
parent
478f372e5910
6 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+2 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+2 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+20 −26app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+9 −3
VERSION+1 −1
@@ -1 +1 @@1-2.9.91+2.9.10

view file

app/build.gradle+1 −1
@@ -32,7 +32,7 @@3232         minSdk 263333         targetSdk 353434         // Keep the Android code in Gradle; the semantic name comes from root VERSION.35-        versionCode 24015535+        versionCode 2401563636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+2 −1
@@ -225,7 +225,8 @@225225                         boundDeps.markSynced(adopted.deviceId)226226                     }227227                 }228-            } catch (_: Exception) {228+            } catch (error: Exception) {229+                if (error is SnapshotDuressFrameTooLargeException) throw error229230                 // Fallback: receiver uses the sync pipeline.230231             }231232         }

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+2 −1
@@ -353,7 +353,8 @@353353                         boundDeps.markSynced(adopted.deviceId)354354                     }355355                 }356-            } catch (_: Exception) {356+            } catch (error: Exception) {357+                if (error is SnapshotDuressFrameTooLargeException) throw error357358                 // Fallback: receiver uses the sync pipeline.358359             }359360         }

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+20 −26
@@ -493,13 +493,13 @@493493  *     vault. We prioritize duress delivery over completeness of entries.494494  *495495  * Oversized duress: duress that itself exceeds [SNAPSHOT_FRAME_WIRE_CEILING]496- * is rejected with a thrown error BEFORE the duress frame is sent. Hard stop;496+ * is rejected with a thrown error BEFORE any snapshot frame is sent. Hard stop;497497  * the caller must split duress out of band. We do not silently truncate498498  * duress; sync cannot restore it.499499  *500500  * Backward compat: pre-v2.9.9 senders put `duress_slot` on the entries501501  * final chunk. `receiveVaultSnapshot` reads `duress_slot` off any chunk (a502- * duress-only frame has empty `entries`/`tombstones` which merge as a no-op),502+ * duress-only frame has empty `entries` and the complete tombstone map),503503  * so both shapes decode and both new->old / old->new directions interoperate.504504  */505505 suspend fun sendVaultSnapshot(@@ -535,7 +535,21 @@535535     val entriesChunkCount = minOf(chunks.size, entryCap)536536     val sendDuress = includeDuress537537     val totalChunks = entriesChunkCount + (if (sendDuress) 1 else 0)538+    val duressFrame = if (sendDuress) AdoptionFrame.Snapshot(539+        chunkIndex = entriesChunkCount,540+        chunkCount = totalChunks,541+        entries = emptyMap(),542+        tombstones = tombstones,543+        duressSlot = duressSlot,544+    ) else null538545 546+    if (547+        duressFrame != null &&548+        AdoptJson.encodeToString(duressFrame).encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING549+    ) {550+        throw SnapshotDuressFrameTooLargeException()551+    }552+539553     for (index in 0 until entriesChunkCount) {540554         val isFinalEntries = index == entriesChunkCount - 1541555         sendFrame(channel, AdoptionFrame.Snapshot(@@ -550,31 +564,11 @@550564         ))551565     }552566 553-    if (sendDuress) {554-        // Pre-check: a duress frame that exceeds the wire ceiling cannot be555-        // sent and would fail the receiver's size check. Throw loudly so the556-        // caller can split duress out of band instead of triggering a silent557-        // "adoption succeeded but duress vanished" path. Sync cannot recover558-        // duress on Android either — the spec is a hard stop.559-        val duressEnvelope = (AdoptJson.encodeToString(AdoptionFrame.Snapshot(560-            chunkIndex = entriesChunkCount,561-            chunkCount = totalChunks,562-            entries = emptyMap(),563-            tombstones = tombstones,564-            duressSlot = duressSlot,565-        ))).encodeToByteArray()566-        if (duressEnvelope.size > SNAPSHOT_FRAME_WIRE_CEILING) {567-            throw IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")568-        }569-        sendFrame(channel, AdoptionFrame.Snapshot(570-            chunkIndex = entriesChunkCount,571-            chunkCount = totalChunks,572-            entries = emptyMap(),573-            tombstones = tombstones,574-            duressSlot = duressSlot,575-        ))576-    }567+    if (duressFrame != null) sendFrame(channel, duressFrame)577568 }569+570+internal class SnapshotDuressFrameTooLargeException :571+    IllegalStateException("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE")578572 579573 /**580574  * Receiver side: receive and reassemble every snapshot chunk. Validates the

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+9 −3
@@ -350,11 +350,17 @@350350     @Test351351     fun `duress larger than wire ceiling throws loudly before sending`() = runBlocking {352352         val (source, _) = memoryPair()353+        var frames = 0354+        val countingSource = object : PairingTransport {355+            override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }356+            override suspend fun receive(): ByteArray = source.receive()357+            override suspend fun close() {}358+        }353359         val duressJson = "x".repeat(800 * 1024) // 800 KiB duress, exceeds 768 KiB ceiling354360         val error = runCatching {355-            sendVaultSnapshot(source, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)361+            sendVaultSnapshot(countingSource, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)356362         }.exceptionOrNull()357-        assertTrue(error is IllegalStateException)358-        assertTrue(error!!.message!!.contains("DURESS_FRAME_TOO_LARGE"))363+        assertTrue(error is SnapshotDuressFrameTooLargeException)364+        assertEquals(0, frames)359365     }360366 }

view file