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
↓ peach-android-b6208af4.tar.gzatlas at this save
files changed4 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 −0app/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+}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+}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+}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+}