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
↓ peach-android-fe1e4e78.tar.gzatlas at this save
files changed6 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 −0app/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)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) {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 = connectedapp/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,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+}