RelayFrames.kt
1package com.peachpasswords.sync23import kotlinx.serialization.SerialName4import kotlinx.serialization.Serializable5import kotlinx.serialization.json.Json6import kotlinx.serialization.json.JsonObject7import kotlinx.serialization.json.JsonPrimitive89/**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 KK for relayed reconnect traffic is 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@Serializable39sealed class RelayMessage {40 /**41 * [targetDeviceId] is the destination's Noise 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()4950 @Serializable51 @SerialName("RELAY_ACCEPT")52 data object Accept : RelayMessage()5354 @Serializable55 @SerialName("RELAY_UNAVAILABLE")56 data object Unavailable : RelayMessage()5758 @Serializable59 @SerialName("RELAY_DISABLED")60 data object Disabled : RelayMessage()6162 /** 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()6667 /** [payload] is base64 of bytes the relay must not interpret. */68 @Serializable69 @SerialName("RELAY_FRAME")70 data class Payload(val payload: String) : RelayMessage()7172 @Serializable73 @SerialName("RELAY_CLOSE")74 data object Close : RelayMessage()75}7677val RelayJson: Json = Json {78 classDiscriminator = "type"79 encodeDefaults = true80 ignoreUnknownKeys = true81}8283fun RelayMessage.encodeToBytes(): ByteArray =84 RelayJson.encodeToString(RelayMessage.serializer(), this).toByteArray(Charsets.UTF_8)8586/**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 */93fun 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}