6ab683d0e9fd

fix: Size Android duress by exact wire encoding (v2.9.11)

time
2026-07-30 23:31 UTC · 2 months ago
machine
koh2
parent
c6c7bcb3aa5d
5 files VERSION+1 −1app/build.gradle+1 −1app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+2 −1app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+87 −0app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+6 −1
VERSION+1 −1
@@ -1 +1 @@1-2.9.101+2.9.11

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 24015635+        versionCode 2401573636         versionName semanticVersion3737 3838         testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"

view file

app/src/main/java/com/peachpasswords/sync/VaultAdopt.kt+2 −1
@@ -545,7 +545,8 @@545545 546546     if (547547         duressFrame != null &&548-        AdoptJson.encodeToString(duressFrame).encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING548+        AdoptJson.encodeToString(AdoptionFrame.serializer(), duressFrame)549+            .encodeToByteArray().size > SNAPSHOT_FRAME_WIRE_CEILING549550     ) {550551         throw SnapshotDuressFrameTooLargeException()551552     }

view file

app/src/test/java/com/peachpasswords/sync/RelayAdoptionReceiverTest.kt+87 −0
@@ -8,6 +8,7 @@88 import org.junit.Assert.assertNotNull99 import org.junit.Assert.assertTrue1010 import org.junit.Test11+import kotlin.system.measureTimeMillis1112 1213 /**1314  * Item 2 (REVIEW_DEFERRED_ITEMS_BRIEF.md): receiver inline-snapshot happy-path.@@ -230,5 +231,91 @@230231         assertFalse("markSynced must NOT be called when no snapshot was applied", receiverMarkedSynced)231232         assertNotNull(receiverStored)232233         assertNotNull(sourceStored)234+    }235+236+    @Test fun `receiver skips the inline wait when the grant is not snapshot capable`() = runBlocking {237+        val (sourceSocket, receiverSocket) = socketPair()238+        val token = "AAECAwQFBgcICQoLDA0ODw"239+        val relay = "wss://relay.example.test"240+        val root = ByteArray(32) { (it + 7).toByte() }241+        val sourceKeys = deriveLocalSyncKeypair(root, "old-source-id")242+        val receiverKeys = deriveLocalSyncKeypair(root, "receiver-id")243+        val sourceIdentity = identity("old-source", sourceKeys.publicKey)244+        val receiverIdentity = identity("receiver", receiverKeys.publicKey)245+        var receiverApplied = false246+247+        val sourceJob = async {248+            sourceSocket.receive()249+            sourceSocket.send(250+                org.json.JSONObject().put("type", "PEACH_HELLO_ACK")251+                    .put("ephemeralPublicKeyHex", Rendezvous.bytesToHex(sourceKeys.publicKey))252+                    .toString().toByteArray(),253+            )254+            val noise = noiseNKResponder(255+                sourceKeys.secretKey,256+                sourceKeys.publicKey,257+                sourceSocket::send,258+                sourceSocket::receive,259+            )260+            try {261+                val channel = object : PairingTransport {262+                    override suspend fun send(msg: ByteArray) = sourceSocket.send(noise.encrypt(msg))263+                    override suspend fun receive(): ByteArray = noise.decrypt(sourceSocket.receive())264+                    override suspend fun close() = sourceSocket.close()265+                }266+                assertTrue(receiveFrame(channel) is AdoptionFrame.Request)267+                assertTrue(receiveFrame(channel) is AdoptionFrame.Confirm)268+                sendFrame(channel, AdoptionFrame.Grant(269+                    rootKeyHex = Rendezvous.bytesToHex(root),270+                    vaultId = computeVaultId(root),271+                    license = "PPK-test",272+                    source = AdoptionFrame.Source(273+                        deviceId = sourceIdentity.deviceId,274+                        deviceName = sourceIdentity.deviceName,275+                        deviceType = sourceIdentity.deviceType.wireValue,276+                        longTermPublicKeyHex = sourceIdentity.longTermPublicKeyHex,277+                        signingPublicKeyHex = sourceIdentity.signingPublicKeyHex,278+                    ),279+                    snapshotCapable = false,280+                ))281+                assertTrue(receiveFrame(channel) is AdoptionFrame.Identity)282+                sendFrame(channel, AdoptionFrame.Complete)283+                assertTrue(receiveFrame(channel) is AdoptionFrame.Applied)284+            } finally {285+                noise.close()286+            }287+        }288+289+        var received: PairedDevice? = null290+        val elapsedMs = measureTimeMillis {291+            received = runRelayAdoptionReceiverSession(292+                receiverSocket,293+                PairingInvitation(relay, token),294+                object : AdoptionReceiverDeps {295+                    override val deviceName = "receiver"296+                    override val deviceType = DeviceType.ANDROID297+                    override fun generateDeviceId() = "session-only-request"298+                    override suspend fun confirmSas() = true299+                    override suspend fun createVaultFromRootKey(rootKeyHex: String) =300+                        computeVaultId(hexToPubkey(rootKeyHex))301+                    override suspend fun deriveLocalIdentity() = receiverIdentity302+                    override suspend fun upsertPairedDevice(device: PairedDevice) = Unit303+                    override suspend fun applySnapshot(304+                        entries: Map<String, String>,305+                        tombstones: Map<String, Long>,306+                        duressSlot: String?,307+                    ) {308+                        receiverApplied = true309+                    }310+                    override fun onError(error: String) = throw AssertionError(error)311+                },312+                confirm = { true },313+            )314+        }315+316+        assertNotNull(received)317+        sourceJob.await()318+        assertFalse(receiverApplied)319+        assertTrue("legacy grant should skip the 15s snapshot wait", elapsedMs < 5_000)233320     }234321 }

view file

app/src/test/java/com/peachpasswords/sync/VaultAdoptSnapshotTest.kt+6 −1
@@ -356,7 +356,12 @@356356             override suspend fun receive(): ByteArray = source.receive()357357             override suspend fun close() {}358358         }359-        val duressJson = "x".repeat(800 * 1024) // 800 KiB duress, exceeds 768 KiB ceiling359+        val ceiling = 768 * 1024360+        val emptyWireSize = AdoptJson.encodeToString(361+            AdoptionFrame.serializer(),362+            AdoptionFrame.Snapshot(1, 2, emptyMap(), emptyMap(), ""),363+        ).encodeToByteArray().size364+        val duressJson = "x".repeat(ceiling - emptyWireSize + 1)360365         val error = runCatching {361366             sendVaultSnapshot(countingSource, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)362367         }.exceptionOrNull()

view file