b6208af4fe5e

add RELAY_ frame format and the phone-side relay hub that pairs two links and forwards verbatim

time
2026-07-25 00:39 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
browser relay dispatch
parent
423c314f718a
4 files app/src/main/java/com/peachpasswords/sync/RelayFrames.kt+100 −0app/src/main/java/com/peachpasswords/sync/RelayHub.kt+163 −0app/src/test/java/com/peachpasswords/sync/RelayFramesTest.kt+53 −0app/src/test/java/com/peachpasswords/sync/RelayHubTest.kt+162 −0
app/src/main/java/com/peachpasswords/sync/RelayFrames.kt+100 −0
@@ -0,0 +1,100 @@1+package com.peachpasswords.sync2+3+import kotlinx.serialization.SerialName4+import kotlinx.serialization.Serializable5+import kotlinx.serialization.json.Json6+import kotlinx.serialization.json.JsonObject7+import kotlinx.serialization.json.JsonPrimitive8+9+/**10+ * Browser-to-browser relay frames — the wire addition from11+ * `PEACH_SYNC_RELAY_DESIGN.md` §3, made concrete.12+ *13+ * Two browser extensions can never connect to each other: neither can listen.14+ * So a phone already paired with both forwards between them. The phone is a15+ * pipe, not a peer — Noise NK for relayed traffic is established end-to-end16+ * between the two laptops, and [Payload.payload] is opaque here by design.17+ *18+ * These frames ride the SAME encrypted channel as ordinary sync traffic: each19+ * one is JSON with a `type` discriminator (matching [SyncMessage] and the20+ * browser's `sync-types.ts`), encrypted under the phone↔laptop Noise session21+ * like everything else. That link encryption is per-hop and is not what22+ * protects the payload; the inner end-to-end session is.23+ *24+ * Direction of each frame:25+ *   RELAY_REQUEST      requester → phone26+ *   RELAY_ACCEPT       phone → requester   (a channel is now open)27+ *   RELAY_UNAVAILABLE  phone → requester   (target not reachable through me)28+ *   RELAY_DISABLED     phone → requester   (relaying turned off in Settings)29+ *   RELAY_OPEN         phone → target      (someone is relaying to you)30+ *   RELAY_FRAME        either way, forwarded verbatim31+ *   RELAY_CLOSE        either way, ends the channel32+ *33+ * RELAY_UNAVAILABLE and RELAY_DISABLED are deliberately distinct: the correct34+ * response differs. Disabled means "try a different relay-capable device";35+ * unavailable means "the target isn't here right now — retry, or try another36+ * relay in case it connects there instead."37+ */38+@Serializable39+sealed class RelayMessage {40+    /**41+     * [targetDeviceId] is the destination's Noise NK static public key as42+     * lowercase hex — the design doc's field name, but keyed on the public key43+     * rather than the `deviceId` string, since the key is what both the phone's44+     * registry and the requester can name unambiguously.45+     */46+    @Serializable47+    @SerialName("RELAY_REQUEST")48+    data class Request(val targetDeviceId: String) : RelayMessage()49+50+    @Serializable51+    @SerialName("RELAY_ACCEPT")52+    data object Accept : RelayMessage()53+54+    @Serializable55+    @SerialName("RELAY_UNAVAILABLE")56+    data object Unavailable : RelayMessage()57+58+    @Serializable59+    @SerialName("RELAY_DISABLED")60+    data object Disabled : RelayMessage()61+62+    /** Tells the target which peer it is now connected to, by public key hex. */63+    @Serializable64+    @SerialName("RELAY_OPEN")65+    data class Open(val peerDeviceId: String) : RelayMessage()66+67+    /** [payload] is base64 of bytes the relay must not interpret. */68+    @Serializable69+    @SerialName("RELAY_FRAME")70+    data class Payload(val payload: String) : RelayMessage()71+72+    @Serializable73+    @SerialName("RELAY_CLOSE")74+    data object Close : RelayMessage()75+}76+77+val RelayJson: Json = Json {78+    classDiscriminator = "type"79+    encodeDefaults = true80+    ignoreUnknownKeys = true81+}82+83+fun RelayMessage.encodeToBytes(): ByteArray =84+    RelayJson.encodeToString(RelayMessage.serializer(), this).toByteArray(Charsets.UTF_8)85+86+/**87+ * Returns null when [bytes] is not a relay frame at all — which is how the88+ * listener tells "forward this blindly" apart from "this is sync traffic for my89+ * own vault". Sniffing the discriminator rather than catching a decode failure90+ * keeps a malformed *relay* frame loud (it throws) while an ordinary SYNC_ frame91+ * is simply not ours to handle.92+ */93+fun decodeRelayMessage(bytes: ByteArray): RelayMessage? {94+    val text = runCatching { String(bytes, Charsets.UTF_8) }.getOrNull() ?: return null95+    val type = runCatching {96+        (RelayJson.parseToJsonElement(text) as? JsonObject)?.get("type") as? JsonPrimitive97+    }.getOrNull()?.content ?: return null98+    if (!type.startsWith("RELAY_")) return null99+    return RelayJson.decodeFromString(RelayMessage.serializer(), text)100+}

view file

app/src/main/java/com/peachpasswords/sync/RelayHub.kt+163 −0
@@ -0,0 +1,163 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.sync.Mutex4+import kotlinx.coroutines.sync.withLock5+import javax.inject.Inject6+import javax.inject.Singleton7+8+/** Whether this device will relay for others. Settings-backed; see [RelaySettingsStore]. */9+interface RelayPolicy {10+    fun isRelayEnabled(): Boolean11+}12+13+/**14+ * One authenticated peer connection, as seen by the relay.15+ *16+ * A link is in exactly one mode at a time, which is what keeps the single17+ * Noise session per connection safe to write to: while SYNCING the connection's18+ * own coroutine is the only writer, and while RELAYING the partner's read loop19+ * is. The mutex covers the overlap at the edges (RELAY_OPEN/RELAY_CLOSE).20+ */21+class RelayLink internal constructor(22+    val peerKeyHex: String,23+    private val sendPlaintext: suspend (ByteArray) -> Unit,24+) {25+    internal enum class Mode { IDLE, SYNCING, RELAYING }26+27+    private val writeLock = Mutex()28+    internal var mode: Mode = Mode.IDLE29+    internal var partner: RelayLink? = null30+31+    internal suspend fun send(message: RelayMessage) = sendRaw(message.encodeToBytes())32+33+    /** Sends already-encoded plaintext — the forwarding path never re-encodes. */34+    internal suspend fun sendRaw(frame: ByteArray) = writeLock.withLock { sendPlaintext(frame) }35+}36+37+/**38+ * Phone-side relay dispatch (`PEACH_SYNC_RELAY_DESIGN.md` §2 and §4).39+ *40+ * Two laptops can never connect to each other — neither can listen — so a phone41+ * paired with both forwards between them. Frames pass through verbatim: the42+ * relay decrypts only the per-hop link encryption it is a party to, sees a43+ * RELAY_FRAME wrapper, and re-encrypts the same bytes onto the other link. The44+ * inner Noise NK session belongs to the two laptops and terminates there.45+ *46+ * The hub holds every live authenticated connection keyed by the peer's static47+ * public key, which is the addressing layer the design calls for — nothing else48+ * on the wire identifies a destination.49+ *50+ * Multiple laptop pairs can be relayed at once (design §4); what is *not*51+ * allowed is one link serving two channels, since a RELAY_FRAME carries no52+ * channel id and would be ambiguous.53+ */54+@Singleton55+class RelayHub @Inject constructor(56+    private val policy: RelayPolicy,57+) {58+    private val lock = Mutex()59+    private val links = mutableMapOf<String, RelayLink>()60+61+    /** Registers an authenticated connection. Call [unregister] when it ends. */62+    suspend fun register(peerKeyHex: String, sendPlaintext: suspend (ByteArray) -> Unit): RelayLink {63+        val link = RelayLink(peerKeyHex.lowercase(), sendPlaintext)64+        lock.withLock { links[link.peerKeyHex] = link }65+        return link66+    }67+68+    suspend fun unregister(link: RelayLink) {69+        val partner = lock.withLock {70+            if (links[link.peerKeyHex] === link) links.remove(link.peerKeyHex)71+            detachLocked(link)72+        }73+        // A relayed peer whose partner vanished would otherwise sit waiting on a74+        // channel that can never deliver another frame.75+        partner?.let { runCatching { it.send(RelayMessage.Close) } }76+    }77+78+    /** Marks a link busy with its own vault's sync, so it is not offered as a relay target. */79+    suspend fun beginSync(link: RelayLink): Boolean = lock.withLock {80+        if (link.mode != RelayLink.Mode.IDLE) return@withLock false81+        link.mode = RelayLink.Mode.SYNCING82+        true83+    }84+85+    suspend fun endSync(link: RelayLink) = lock.withLock {86+        if (link.mode == RelayLink.Mode.SYNCING) link.mode = RelayLink.Mode.IDLE87+    }88+89+    /**90+     * Answers a RELAY_REQUEST. On success both links enter RELAYING and every91+     * later RELAY_FRAME on either is forwarded to the other.92+     */93+    suspend fun onRequest(from: RelayLink, targetKeyHex: String) {94+        if (!policy.isRelayEnabled()) {95+            from.send(RelayMessage.Disabled)96+            return97+        }98+        val target = lock.withLock {99+            val candidate = links[targetKeyHex.lowercase()]100+            if (candidate == null ||101+                candidate === from ||102+                candidate.mode != RelayLink.Mode.IDLE ||103+                from.mode != RelayLink.Mode.IDLE104+            ) {105+                null106+            } else {107+                from.partner = candidate108+                candidate.partner = from109+                from.mode = RelayLink.Mode.RELAYING110+                candidate.mode = RelayLink.Mode.RELAYING111+                candidate112+            }113+        }114+        if (target == null) {115+            from.send(RelayMessage.Unavailable)116+            return117+        }118+        // The target learns first: an ACCEPT that beat the OPEN would invite the119+        // requester to send frames into a peer that has not been told to expect120+        // them.121+        try {122+            target.send(RelayMessage.Open(from.peerKeyHex))123+        } catch (_: Exception) {124+            lock.withLock { detachLocked(from) }125+            from.send(RelayMessage.Unavailable)126+            return127+        }128+        from.send(RelayMessage.Accept)129+    }130+131+    /**132+     * Forwards one frame to the link's partner, byte for byte. Returns false if133+     * there is no channel, which the caller answers with RELAY_CLOSE.134+     */135+    suspend fun forward(from: RelayLink, frame: ByteArray): Boolean {136+        val partner = lock.withLock { from.partner } ?: return false137+        return try {138+            partner.sendRaw(frame)139+            true140+        } catch (_: Exception) {141+            closeChannel(from)142+            false143+        }144+    }145+146+    /** Tears the channel down and tells the other end, if there is one. */147+    suspend fun closeChannel(link: RelayLink) {148+        val partner = lock.withLock { detachLocked(link) }149+        partner?.let { runCatching { it.send(RelayMessage.Close) } }150+    }151+152+    /** Must be called with [lock] held. Returns the detached partner, if any. */153+    private fun detachLocked(link: RelayLink): RelayLink? {154+        val partner = link.partner155+        link.partner = null156+        if (link.mode == RelayLink.Mode.RELAYING) link.mode = RelayLink.Mode.IDLE157+        if (partner != null) {158+            partner.partner = null159+            if (partner.mode == RelayLink.Mode.RELAYING) partner.mode = RelayLink.Mode.IDLE160+        }161+        return partner162+    }163+}

view file

app/src/test/java/com/peachpasswords/sync/RelayFramesTest.kt+53 −0
@@ -0,0 +1,53 @@1+package com.peachpasswords.sync2+3+import org.junit.Assert.assertEquals4+import org.junit.Assert.assertNull5+import org.junit.Assert.assertTrue6+import org.junit.Test7+8+/**9+ * The relay frames share a channel with ordinary sync frames, so the one thing10+ * that must never break is telling them apart: a sync frame misread as a relay11+ * frame would be forwarded to another device, and a relay frame misread as sync12+ * would be handed to the vault.13+ */14+class RelayFramesTest {15+16+    @Test fun `a relay request round-trips`() {17+        val key = "ab".repeat(32)18+        val decoded = decodeRelayMessage(RelayMessage.Request(key).encodeToBytes())19+        assertEquals(RelayMessage.Request(key), decoded)20+    }21+22+    @Test fun `frames use the type discriminator the browser sync frames use`() {23+        val json = String(RelayMessage.Request("00".repeat(32)).encodeToBytes(), Charsets.UTF_8)24+        assertTrue(json, json.contains("\"type\":\"RELAY_REQUEST\""))25+        assertTrue(json, json.contains("\"targetDeviceId\":"))26+    }27+28+    @Test fun `sync traffic is not mistaken for a relay frame`() {29+        val hello = SyncMessage.Hello("v", "d", 1, 2).encodeToBytes()30+        assertNull(decodeRelayMessage(hello))31+    }32+33+    @Test fun `junk and empty frames are not relay frames`() {34+        assertNull(decodeRelayMessage(ByteArray(0)))35+        assertNull(decodeRelayMessage(byteArrayOf(0x01, 0x02, 0x03)))36+        assertNull(decodeRelayMessage("not json".toByteArray()))37+    }38+39+    @Test fun `a payload survives forwarding unchanged`() {40+        val opaque = java.util.Base64.getEncoder().encodeToString(byteArrayOf(0, 1, 2, -1, -2))41+        val decoded = decodeRelayMessage(RelayMessage.Payload(opaque).encodeToBytes())42+        assertEquals(opaque, (decoded as RelayMessage.Payload).payload)43+    }44+45+    @Test fun `the parameterless responses decode by type alone`() {46+        listOf(47+            RelayMessage.Accept,48+            RelayMessage.Unavailable,49+            RelayMessage.Disabled,50+            RelayMessage.Close,51+        ).forEach { assertEquals(it, decodeRelayMessage(it.encodeToBytes())) }52+    }53+}

view file

app/src/test/java/com/peachpasswords/sync/RelayHubTest.kt+162 −0
@@ -0,0 +1,162 @@1+package com.peachpasswords.sync2+3+import kotlinx.coroutines.runBlocking4+import org.junit.Assert.assertArrayEquals5+import org.junit.Assert.assertEquals6+import org.junit.Assert.assertTrue7+import org.junit.Test8+9+/**10+ * The relay's whole job is to move bytes it must not understand between two11+ * peers that cannot reach each other. The failures worth pinning are the quiet12+ * ones: a rejection reason that collapses into the wrong one (the requester then13+ * retries the same dead relay forever), and a payload that gets re-encoded on14+ * the way through (the end-to-end session breaks with nothing in the logs).15+ */16+class RelayHubTest {17+18+    private val keyA = "aa".repeat(32)19+    private val keyB = "bb".repeat(32)20+21+    private class Peer {22+        val sent = mutableListOf<ByteArray>()23+        val messages: List<RelayMessage?> get() = sent.map { decodeRelayMessage(it) }24+        val sink: suspend (ByteArray) -> Unit = { sent.add(it) }25+    }26+27+    private fun hub(enabled: Boolean = true) = RelayHub(object : RelayPolicy {28+        override fun isRelayEnabled() = enabled29+    })30+31+    @Test fun `relaying turned off is reported as disabled, not unavailable`() = runBlocking {32+        val hub = hub(enabled = false)33+        val a = Peer()34+        val b = Peer()35+        val linkA = hub.register(keyA, a.sink)36+        hub.register(keyB, b.sink)37+38+        hub.onRequest(linkA, keyB)39+40+        assertEquals(listOf(RelayMessage.Disabled), a.messages)41+        assertTrue("the target must not be told about a refused request", b.sent.isEmpty())42+    }43+44+    @Test fun `an absent target is unavailable`() = runBlocking {45+        val hub = hub()46+        val a = Peer()47+        val linkA = hub.register(keyA, a.sink)48+49+        hub.onRequest(linkA, keyB)50+51+        assertEquals(listOf(RelayMessage.Unavailable), a.messages)52+    }53+54+    @Test fun `a target busy with its own sync is unavailable`() = runBlocking {55+        val hub = hub()56+        val a = Peer()57+        val b = Peer()58+        val linkA = hub.register(keyA, a.sink)59+        val linkB = hub.register(keyB, b.sink)60+        hub.beginSync(linkB)61+62+        hub.onRequest(linkA, keyB)63+        assertEquals(listOf(RelayMessage.Unavailable), a.messages)64+65+        // …and becomes available again once that sync finishes.66+        hub.endSync(linkB)67+        hub.onRequest(linkA, keyB)68+        assertEquals(RelayMessage.Accept, a.messages.last())69+    }70+71+    @Test fun `a channel opens with the target told first`() = runBlocking {72+        val hub = hub()73+        val a = Peer()74+        val b = Peer()75+        val linkA = hub.register(keyA, a.sink)76+        hub.register(keyB, b.sink)77+78+        hub.onRequest(linkA, keyB)79+80+        assertEquals(listOf(RelayMessage.Open(keyA)), b.messages)81+        assertEquals(listOf(RelayMessage.Accept), a.messages)82+    }83+84+    @Test fun `frames cross verbatim in both directions`() = runBlocking {85+        val hub = hub()86+        val a = Peer()87+        val b = Peer()88+        val linkA = hub.register(keyA, a.sink)89+        val linkB = hub.register(keyB, b.sink)90+        hub.onRequest(linkA, keyB)91+92+        // Not a RelayMessage encoding — whatever arrived must land unaltered,93+        // which is exactly what the relay promises about the inner session.94+        val opaque = "{\"type\":\"RELAY_FRAME\",\"payload\":\"ZmFrZQ==\"}".toByteArray()95+        assertTrue(hub.forward(linkA, opaque))96+        assertArrayEqualsLast(opaque, b.sent)97+98+        val back = "{\"type\":\"RELAY_FRAME\",\"payload\":\"b3RoZXI=\"}".toByteArray()99+        assertTrue(hub.forward(linkB, back))100+        assertArrayEqualsLast(back, a.sent)101+    }102+103+    @Test fun `forwarding without a channel fails rather than guessing a destination`() = runBlocking {104+        val hub = hub()105+        val a = Peer()106+        hub.register(keyB, Peer().sink)107+        val linkA = hub.register(keyA, a.sink)108+109+        assertEquals(false, hub.forward(linkA, "x".toByteArray()))110+    }111+112+    @Test fun `a dropped connection closes the other end of the channel`() = runBlocking {113+        val hub = hub()114+        val a = Peer()115+        val b = Peer()116+        val linkA = hub.register(keyA, a.sink)117+        val linkB = hub.register(keyB, b.sink)118+        hub.onRequest(linkA, keyB)119+120+        hub.unregister(linkA)121+122+        assertEquals(RelayMessage.Close, b.messages.last())123+        // B is free to serve a new channel afterwards.124+        val c = Peer()125+        val linkC = hub.register("cc".repeat(32), c.sink)126+        hub.onRequest(linkC, keyB)127+        assertEquals(RelayMessage.Accept, c.messages.last())128+    }129+130+    @Test fun `a link cannot relay to itself`() = runBlocking {131+        val hub = hub()132+        val a = Peer()133+        val linkA = hub.register(keyA, a.sink)134+135+        hub.onRequest(linkA, keyA)136+137+        assertEquals(listOf(RelayMessage.Unavailable), a.messages)138+    }139+140+    @Test fun `two laptop pairs can be relayed at once`() = runBlocking {141+        val hub = hub()142+        val peers = List(4) { Peer() }143+        val keys = List(4) { "0$it".repeat(32) }144+        val links = keys.mapIndexed { i, k -> hub.register(k, peers[i].sink) }145+146+        hub.onRequest(links[0], keys[1])147+        hub.onRequest(links[2], keys[3])148+149+        assertEquals(RelayMessage.Accept, peers[0].messages.last())150+        assertEquals(RelayMessage.Accept, peers[2].messages.last())151+        assertTrue(hub.forward(links[0], "one".toByteArray()))152+        assertTrue(hub.forward(links[2], "two".toByteArray()))153+        assertArrayEqualsLast("one".toByteArray(), peers[1].sent)154+        assertArrayEqualsLast("two".toByteArray(), peers[3].sent)155+    }156+157+    private fun assertArrayEqualsLast(expected: ByteArray, sent: List<ByteArray>) {158+        val last = sent.lastOrNull()159+        assertTrue("nothing was forwarded", last != null)160+        assertArrayEquals(expected, last)161+    }162+}

view file