PairingFrames.kt
1package com.peachpasswords.sync23import kotlinx.coroutines.Dispatchers4import kotlinx.coroutines.withContext5import java.io.DataInputStream6import java.io.EOFException7import java.io.InputStream8import java.io.OutputStream910/**11 * Length-delimited frame IO for a pairing/sync connection.12 *13 * Originally declared alongside the Chirp audio pairing that has since been14 * removed, but it was never audio-specific — it is just "send me a frame, give15 * me a frame", which the Noise NK pipeline and the adoption flow both build on.16 * It lives here now, with the frame helpers that use it.17 */18interface PairingTransport {19 suspend fun send(msg: ByteArray)20 suspend fun receive(): ByteArray21 suspend fun close()22}2324/**25 * Length-prefixed framing for the C7 TCP transport, shared by [PairingServer]26 * and [PairingTransport] so neither duplicates the wire format.27 *28 * Frame = [4-byte big-endian length][payload]. This is the framing the existing29 * PairingFrames.runPairingInitiator/Responder already assume implicitly when they30 * call `transport.send(bytes)` / `transport.receive()` — the WebSocket transport31 * gets framing for free from the protocol; a raw TCP socket does not, so we add32 * it here.33 *34 * A frame length cap guards against a hostile/garbled peer asking us to allocate35 * gigabytes before the Noise layer has authenticated anything.36 */37const val MAX_PAIRING_FRAME_BYTES: Int = 1 shl 20 // 1 MiB3839/** Blocking write of a single length-prefixed frame. */40fun writeFrame(out: OutputStream, payload: ByteArray) {41 require(payload.size <= MAX_PAIRING_FRAME_BYTES) { "frame too large: ${payload.size}" }42 val header = ByteArray(4)43 header[0] = (payload.size ushr 24).toByte()44 header[1] = (payload.size ushr 16).toByte()45 header[2] = (payload.size ushr 8).toByte()46 header[3] = payload.size.toByte()47 out.write(header)48 out.write(payload)49 out.flush()50}5152/** Blocking read of a single length-prefixed frame. Throws on EOF / oversize. */53fun readFrame(input: InputStream): ByteArray {54 val data = if (input is DataInputStream) input else DataInputStream(input)55 val length = try {56 data.readInt()57 } catch (e: EOFException) {58 throw java.io.IOException("peer closed before frame header", e)59 }60 require(length in 0..MAX_PAIRING_FRAME_BYTES) { "invalid frame length: $length" }61 val buf = ByteArray(length)62 data.readFully(buf)63 return buf64}6566/**67 * Adapts a blocking TCP socket pair to the suspend send/receive shape the REAL68 * crypto stack expects:69 * - noiseNKInitiator / noiseNKResponder take `send: suspend (ByteArray)->Unit`70 * and `receive: suspend ()->ByteArray` (see NoiseNK.kt).71 * - PairingFrames's [PairingTransport] interface is `send/receive/close`.72 *73 * Both the Noise handshake messages and the post-handshake encrypted payloads74 * travel as length-prefixed frames over the same socket, so one [SocketFrameIO]75 * serves the whole session. All IO hops to Dispatchers.IO.76 *77 * This implements the EXISTING [PairingTransport] interface (PairingFrames.kt),78 * which is the contract `runPairingInitiator` / `runPairingResponder` consume —79 * so a TCP socket can drive the already-written handshake unchanged.80 */81class SocketFrameIO(82 private val input: InputStream,83 private val output: OutputStream,84 private val onClose: suspend () -> Unit = {},85) : PairingTransport {86 private val buffered = DataInputStream(input)8788 /**89 * Synchronous frame write. [SyncSession.send] is non-suspending (see90 * SyncTransport.kt), so the sync-exchange bridge needs a blocking send91 * that does NOT wrap a coroutine. Socket writes are bounded by soTimeout.92 */93 fun sendBlocking(msg: ByteArray) {94 writeFrame(output, msg)95 }9697 override suspend fun send(msg: ByteArray) = withContext(Dispatchers.IO) {98 writeFrame(output, msg)99 }100101 override suspend fun receive(): ByteArray = withContext(Dispatchers.IO) {102 readFrame(buffered)103 }104105 override suspend fun close() {106 onClose()107 }108}109110/**111 * Convenience wrappers for the post-handshake encrypted exchange. The Noise112 * [NoiseNKSession] already owns the cipher (encrypt/decrypt); these just splice113 * it onto the frame layer so call sites read clearly.114 *115 * NOTE: the existing PairingFrames handshake calls `session.encrypt` /116 * `session.decrypt` directly around `transport.send` / `transport.receive`; the117 * post-pairing SYNC exchange (runSyncInitiator/Responder) does the same via118 * a [SyncSession]. These helpers exist for the raw-frame paths in119 * PairingServer/PairingTransport that don't go through a SyncSession.120 */121suspend fun PairingTransport.sendEncrypted(session: NoiseNKSession, plaintext: ByteArray) {122 send(session.encrypt(plaintext))123}124125suspend fun PairingTransport.receiveEncrypted(session: NoiseNKSession): ByteArray {126 return session.decrypt(receive())127}