package com.peachpasswords.sync import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive /** * Browser-to-browser relay frames — the wire addition from * `PEACH_SYNC_RELAY_DESIGN.md` §3, made concrete. * * Two browser extensions can never connect to each other: neither can listen. * So a phone already paired with both forwards between them. The phone is a * pipe, not a peer — Noise KK for relayed reconnect traffic is end-to-end * between the two laptops, and [Payload.payload] is opaque here by design. * * These frames ride the SAME encrypted channel as ordinary sync traffic: each * one is JSON with a `type` discriminator (matching [SyncMessage] and the * browser's `sync-types.ts`), encrypted under the phone↔laptop Noise session * like everything else. That link encryption is per-hop and is not what * protects the payload; the inner end-to-end session is. * * Direction of each frame: * RELAY_REQUEST requester → phone * RELAY_ACCEPT phone → requester (a channel is now open) * RELAY_UNAVAILABLE phone → requester (target not reachable through me) * RELAY_DISABLED phone → requester (relaying turned off in Settings) * RELAY_OPEN phone → target (someone is relaying to you) * RELAY_FRAME either way, forwarded verbatim * RELAY_CLOSE either way, ends the channel * * RELAY_UNAVAILABLE and RELAY_DISABLED are deliberately distinct: the correct * response differs. Disabled means "try a different relay-capable device"; * unavailable means "the target isn't here right now — retry, or try another * relay in case it connects there instead." */ @Serializable sealed class RelayMessage { /** * [targetDeviceId] is the destination's Noise static public key as * lowercase hex — the design doc's field name, but keyed on the public key * rather than the `deviceId` string, since the key is what both the phone's * registry and the requester can name unambiguously. */ @Serializable @SerialName("RELAY_REQUEST") data class Request(val targetDeviceId: String) : RelayMessage() @Serializable @SerialName("RELAY_ACCEPT") data object Accept : RelayMessage() @Serializable @SerialName("RELAY_UNAVAILABLE") data object Unavailable : RelayMessage() @Serializable @SerialName("RELAY_DISABLED") data object Disabled : RelayMessage() /** Tells the target which peer it is now connected to, by public key hex. */ @Serializable @SerialName("RELAY_OPEN") data class Open(val peerDeviceId: String) : RelayMessage() /** [payload] is base64 of bytes the relay must not interpret. */ @Serializable @SerialName("RELAY_FRAME") data class Payload(val payload: String) : RelayMessage() @Serializable @SerialName("RELAY_CLOSE") data object Close : RelayMessage() } val RelayJson: Json = Json { classDiscriminator = "type" encodeDefaults = true ignoreUnknownKeys = true } fun RelayMessage.encodeToBytes(): ByteArray = RelayJson.encodeToString(RelayMessage.serializer(), this).toByteArray(Charsets.UTF_8) /** * Returns null when [bytes] is not a relay frame at all — which is how the * listener tells "forward this blindly" apart from "this is sync traffic for my * own vault". Sniffing the discriminator rather than catching a decode failure * keeps a malformed *relay* frame loud (it throws) while an ordinary SYNC_ frame * is simply not ours to handle. */ fun decodeRelayMessage(bytes: ByteArray): RelayMessage? { val text = runCatching { String(bytes, Charsets.UTF_8) }.getOrNull() ?: return null val type = runCatching { (RelayJson.parseToJsonElement(text) as? JsonObject)?.get("type") as? JsonPrimitive }.getOrNull()?.content ?: return null if (!type.startsWith("RELAY_")) return null return RelayJson.decodeFromString(RelayMessage.serializer(), text) }