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
↓ peach-android-c6c7bcb3.tar.gzatlas at this save
files changed6 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 −3app/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"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 }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 }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 theapp/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 }