RelayFrames.kt

4.0 KB · raw

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}