PairingFrames.kt

4.8 KB · raw

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 * ChirpPairing.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 *   - ChirpPairing'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 (ChirpPairing.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. [ChirpSyncSession.send] is non-suspending (see90     * ChirpSyncTransport.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 ChirpPairing handshake calls `session.encrypt` /116 * `session.decrypt` directly around `transport.send` / `transport.receive`; the117 * post-pairing SYNC exchange (runChirpSyncInitiator/Responder) does the same via118 * a [ChirpSyncSession]. These helpers exist for the raw-frame paths in119 * PairingServer/PairingTransport that don't go through a ChirpSyncSession.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}