fe1e4e781366

bound pairing relay inbox with frame/inbox ceilings (PB-26-205, v2.14.98)

time
2026-09-03 10:43 UTC · 1 month ago
machine
koh2
agent
muse-spark-1.3-contributor-free
session
PB-26-205 pairing inbox limits
parent
bd058d56991a
6 files VERSION+1 −1app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+68 −0app/src/main/java/com/peachpasswords/sync/PairingFrames.kt+86 −0app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+1 −1app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+2 −2app/src/test/java/com/peachpasswords/sync/PairingRelayLimitsTest.kt+126 −0
VERSION+1 −1
@@ -1 +1 @@1-2.14.971+2.14.98

view file

app/src/main/java/com/peachpasswords/sync/CloudRelayReconnectListener.kt+68 −0
@@ -619,6 +619,74 @@619619         .pingInterval(RELAY_PING_INTERVAL_SECONDS, TimeUnit.SECONDS)620620         .build()621621 622+    /**623+     * PB-26-205 pairing/adoption dial: same relay rooms as [connect], but the624+     * inbound path is bounded by the shared pairing ceilings625+     * ([MAX_PAIRING_FRAME_BYTES] per frame, [MAX_PAIRING_INBOX_BYTES] /626+     * [MAX_PAIRING_INBOX_MESSAGES] unconsumed) instead of the sync ceilings.627+     * Any violation closes with status 1009 and fails readers with628+     * [PairingResourceLimitException]. Pairing call sites must use this; the629+     * ongoing-sync paths keep [connect].630+     */631+    suspend fun connectPairing(url: String): RelayBinarySocket = withContext(Dispatchers.IO) {632+        val opened = CompletableDeferred<WebSocket>()633+        lateinit var inbox: RelayPairingInbox634+        var socketRef: WebSocket? = null635+        inbox = RelayPairingInbox { socketRef?.close(1009, "pairing frame too large") }636+        val listener = object : WebSocketListener() {637+            override fun onOpen(webSocket: WebSocket, response: Response) {638+                socketRef = webSocket639+                opened.complete(webSocket)640+            }641+642+            override fun onMessage(webSocket: WebSocket, bytes: ByteString) {643+                // The size check runs before toByteArray() allocates/copies.644+                inbox.onFrame(bytes.size) { bytes.toByteArray() }645+            }646+647+            override fun onMessage(webSocket: WebSocket, text: String) {648+                inbox.reject()649+            }650+651+            override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {652+                val detail = reason.takeIf { it.isNotBlank() }653+                    ?.let(::relayLogDetail)654+                    ?: "no reason"655+                inbox.close(IllegalStateException("relay_closed_$code: $detail"))656+            }657+658+            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {659+                inbox.close(t)660+                opened.completeExceptionally(t)661+            }662+        }663+        val webSocket = client.newWebSocket(Request.Builder().url(url).build(), listener)664+        val connected = try {665+            withTimeout(RELAY_CONNECT_TIMEOUT_MS) { opened.await() }666+        } catch (error: Throwable) {667+            webSocket.cancel()668+            throw error669+        }670+        object : RelayBinarySocket {671+            override fun send(frame: ByteArray) {672+                if (frame.size > MAX_PAIRING_FRAME_BYTES) throw PairingResourceLimitException()673+                check(connected.send(frame.toByteString())) { "relay_send_failed" }674+            }675+676+            override suspend fun receive(): ByteArray = inbox.receive()677+678+            override fun close(code: Int, reason: String) {679+                inbox.close(IllegalStateException("relay_closed_$code: $reason"))680+                connected.close(code, reason)681+                connected.cancel()682+            }683+684+            override fun closeAfterQueuedFrames(code: Int, reason: String) {685+                connected.close(code, reason)686+            }687+        }688+    }689+622690     suspend fun connect(url: String): RelayBinarySocket = withContext(Dispatchers.IO) {623691         val opened = CompletableDeferred<WebSocket>()624692         val incoming = Channel<ByteArray>(RELAY_INBOX_CAPACITY)

view file

app/src/main/java/com/peachpasswords/sync/PairingFrames.kt+86 −0
@@ -1,9 +1,11 @@11 package com.peachpasswords.sync22 33 import kotlinx.coroutines.Dispatchers4+import kotlinx.coroutines.channels.Channel45 import kotlinx.coroutines.withContext56 import java.io.DataInputStream67 import java.io.EOFException8+import java.io.IOException79 import java.io.InputStream810 import java.io.OutputStream911 @@ -35,6 +37,90 @@3537  * gigabytes before the Noise layer has authenticated anything.3638  */3739 const val MAX_PAIRING_FRAME_BYTES: Int = 1 shl 20 // 1 MiB40+41+/**42+ * PB-26-205: relay pairing/adoption inbox backstop, mirrored from the43+ * browser (`pairing-dial.ts`). The 1 MiB frame ceiling clears the 768 KiB44+ * adoption frame ceiling with headroom; the 12 MiB / 256-message inbox45+ * clears the 10 MiB aggregate adoption budget plus handshake/control46+ * overhead. Both clients agree on accept/reject at these boundaries.47+ */48+const val MAX_PAIRING_INBOX_BYTES: Int = 12 * 1024 * 102449+const val MAX_PAIRING_INBOX_MESSAGES: Int = 25650+51+/** Terminal pairing error: an IOException so it lands in the ERRORED outcome like a transport failure. */52+class PairingResourceLimitException(53+    message: String = "pairing_resource_limit",54+) : IOException(message)55+56+/**57+ * Bounded relay inbox for one pairing/adoption session. The size check runs58+ * before [read] performs the allocate/copy, so an oversized frame never59+ * materializes. On any violation the channel closes with60+ * [PairingResourceLimitException] — every parked and future reader gets that61+ * one terminal error — [onLimitExceeded] fires once (the owner closes the62+ * socket with status 1009), and later frames are ignored.63+ */64+internal class RelayPairingInbox(private val onLimitExceeded: () -> Unit) {65+    private val channel = Channel<ByteArray>(Channel.UNLIMITED)66+    private var queuedBytes = 067+    private var queuedMessages = 068+    private var terminal: Throwable? = null69+70+    @Synchronized71+    fun onFrame(size: Int, read: () -> ByteArray): Boolean {72+        if (terminal != null) return false73+        if (74+            size > MAX_PAIRING_FRAME_BYTES ||75+            queuedMessages + 1 > MAX_PAIRING_INBOX_MESSAGES ||76+            queuedBytes + size > MAX_PAIRING_INBOX_BYTES77+        ) {78+            tripLimit()79+            return false80+        }81+        val frame = read()82+        queuedBytes += frame.size83+        queuedMessages += 184+        channel.trySend(frame)85+        return true86+    }87+88+    /** Trips the limit for a non-frame violation (e.g. a text frame). */89+    @Synchronized90+    fun reject(): Boolean {91+        if (terminal != null) return false92+        tripLimit()93+        return false94+    }95+96+    /** Benign socket close/failure: fails parked readers without a limit close. */97+    @Synchronized98+    fun close(cause: Throwable) {99+        if (terminal != null) return100+        terminal = cause101+        channel.close(cause)102+    }103+104+    private fun tripLimit() {105+        terminal = PairingResourceLimitException()106+        // A closed channel still delivers buffered elements first, so drop107+        // the queued refs for parity with the browser's cleared inbox.108+        while (channel.tryReceive().isSuccess) { /* drop */ }109+        queuedBytes = 0110+        queuedMessages = 0111+        channel.close(terminal)112+        onLimitExceeded()113+    }114+115+    suspend fun receive(): ByteArray {116+        val frame = channel.receive()117+        synchronized(this) {118+            queuedMessages -= 1119+            queuedBytes -= frame.size120+        }121+        return frame122+    }123+}38124 39125 /** Blocking write of a single length-prefixed frame. */40126 fun writeFrame(out: OutputStream, payload: ByteArray) {

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionHost.kt+1 −1
@@ -63,7 +63,7 @@6363 6464         job = scope.launch(Dispatchers.IO) {6565             try {66-                val connected = OkHttpRelaySocket.connect(66+                val connected = OkHttpRelaySocket.connectPairing(6767                     Rendezvous.buildUrl(relayUrl, token),6868                 )6969                 socket = connected

view file

app/src/main/java/com/peachpasswords/sync/RelayAdoptionReceiver.kt+2 −2
@@ -33,7 +33,7 @@3333         require(invitation.vaultStatus == VaultStatus.POPULATED) {3434             "receiver adopt() expects a populated invitation; got ${invitation.vaultStatus}"3535         }36-        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())36+        val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())3737         return adoptFromRelaySocket(3838             socket = socket,3939             invitation = invitation,@@ -61,7 +61,7 @@6161         require(invitation.vaultStatus == VaultStatus.EMPTY) {6262             "grantFromInvitation() expects an empty invitation; got ${invitation.vaultStatus}"6363         }64-        val socket = OkHttpRelaySocket.connect(invitation.roomUrl())64+        val socket = OkHttpRelaySocket.connectPairing(invitation.roomUrl())6565         return grantFromRelaySocket(6666             socket = socket,6767             invitation = invitation,

view file

app/src/test/java/com/peachpasswords/sync/PairingRelayLimitsTest.kt+126 −0
@@ -0,0 +1,126 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.async4+import kotlinx.coroutines.launch5+import kotlinx.coroutines.runBlocking6+import org.junit.Assert.assertArrayEquals7+import org.junit.Assert.assertEquals8+import org.junit.Assert.assertFalse9+import org.junit.Assert.assertTrue10+import org.junit.Assert.fail11+import org.junit.Test12+13+/**14+ * PB-26-205 (Android mirror): the pairing/adoption relay inbox is bounded15+ * before any allocate/copy of attacker-controlled frame data, with the same16+ * ceilings as the browser (`src/shared/pairing-dial.ts`). Both clients must17+ * agree on accept/reject at these boundaries.18+ */19+class PairingRelayLimitsTest {20+    private fun inbox(limitCalls: MutableList<Int> = mutableListOf()) =21+        RelayPairingInbox { limitCalls.add(1) }22+23+    @Test fun `shared ceilings match the browser vectors`() {24+        assertEquals(1024 * 1024, MAX_PAIRING_FRAME_BYTES)25+        assertEquals(12 * 1024 * 1024, MAX_PAIRING_INBOX_BYTES)26+        assertEquals(256, MAX_PAIRING_INBOX_MESSAGES)27+    }28+29+    @Test fun `max frame is accepted intact`() = runBlocking {30+        val inbox = inbox()31+        val frame = ByteArray(MAX_PAIRING_FRAME_BYTES) { (it % 7).toByte() }32+        assertTrue(inbox.onFrame(frame.size) { frame.copyOf() })33+        assertArrayEquals(frame, inbox.receive())34+    }35+36+    @Test fun `max-plus-one is refused before the copy`() = runBlocking {37+        val limitCalls = mutableListOf<Int>()38+        val inbox = inbox(limitCalls)39+        var copied = false40+        assertFalse(inbox.onFrame(MAX_PAIRING_FRAME_BYTES + 1) { copied = true; ByteArray(0) })41+        assertFalse("oversize frame must be rejected before read/copy", copied)42+        assertEquals(1, limitCalls.size)43+        try {44+            inbox.receive()45+            fail("expected PairingResourceLimitException")46+        } catch (error: PairingResourceLimitException) {47+            assertEquals("pairing_resource_limit", error.message)48+        }49+    }50+51+    @Test fun `individually valid frames tripping the byte budget close the inbox`() = runBlocking {52+        val limitCalls = mutableListOf<Int>()53+        val inbox = inbox(limitCalls)54+        val frames = MAX_PAIRING_INBOX_BYTES / MAX_PAIRING_FRAME_BYTES55+        repeat(frames) {56+            assertTrue(inbox.onFrame(MAX_PAIRING_FRAME_BYTES) { ByteArray(MAX_PAIRING_FRAME_BYTES) })57+        }58+        assertFalse(inbox.onFrame(1) { ByteArray(1) })59+        assertEquals(1, limitCalls.size)60+        try {61+            inbox.receive()62+            fail("expected PairingResourceLimitException")63+        } catch (_: PairingResourceLimitException) {64+        }65+    }66+67+    @Test fun `tiny frames trip the message-count limit`() = runBlocking {68+        val limitCalls = mutableListOf<Int>()69+        val inbox = inbox(limitCalls)70+        repeat(MAX_PAIRING_INBOX_MESSAGES) {71+            assertTrue(inbox.onFrame(1) { ByteArray(1) })72+        }73+        assertTrue(limitCalls.isEmpty())74+        assertFalse(inbox.onFrame(1) { ByteArray(1) })75+        assertEquals(1, limitCalls.size)76+        try {77+            inbox.receive()78+            fail("expected PairingResourceLimitException")79+        } catch (_: PairingResourceLimitException) {80+        }81+    }82+83+    @Test fun `dequeue relieves the budget for later valid frames`() = runBlocking {84+        val limitCalls = mutableListOf<Int>()85+        val inbox = inbox(limitCalls)86+        repeat(MAX_PAIRING_INBOX_MESSAGES) {87+            assertTrue(inbox.onFrame(1) { ByteArray(1) })88+        }89+        assertEquals(1, inbox.receive().size)90+        assertTrue(inbox.onFrame(1) { ByteArray(1) })91+        assertTrue(limitCalls.isEmpty())92+        repeat(MAX_PAIRING_INBOX_MESSAGES) { inbox.receive() }93+    }94+95+    @Test fun `pending readers share one terminal error and later frames are ignored`() = runBlocking {96+        val limitCalls = mutableListOf<Int>()97+        val inbox = inbox(limitCalls)98+        val first = async {99+            try {100+                inbox.receive()101+                null102+            } catch (error: PairingResourceLimitException) {103+                error104+            }105+        }106+        val second = async {107+            try {108+                inbox.receive()109+                null110+            } catch (error: PairingResourceLimitException) {111+                error112+            }113+        }114+        // Let both readers park, then trip the limit.115+        launch { inbox.onFrame(MAX_PAIRING_FRAME_BYTES + 1) { ByteArray(0) } }.join()116+        assertEquals("pairing_resource_limit", first.await()?.message)117+        assertEquals("pairing_resource_limit", second.await()?.message)118+        assertFalse(inbox.onFrame(1) { ByteArray(1) })119+        assertEquals(1, limitCalls.size)120+        try {121+            inbox.receive()122+            fail("expected PairingResourceLimitException")123+        } catch (_: PairingResourceLimitException) {124+        }125+    }126+}

view file