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-prefixed framing for the C7 TCP transport, shared by [PairingServer]12 * and [PairingTransport] so neither duplicates the wire format.13 *14 * Frame = [4-byte big-endian length][payload]. This is the framing the existing15 * ChirpPairing.runPairingInitiator/Responder already assume implicitly when they16 * call `transport.send(bytes)` / `transport.receive()` — the WebSocket transport17 * gets framing for free from the protocol; a raw TCP socket does not, so we add18 * it here.19 *20 * A frame length cap guards against a hostile/garbled peer asking us to allocate21 * gigabytes before the Noise layer has authenticated anything.22 */23const val MAX_PAIRING_FRAME_BYTES: Int = 1 shl 20 // 1 MiB2425/** Blocking write of a single length-prefixed frame. */26fun writeFrame(out: OutputStream, payload: ByteArray) {27 require(payload.size <= MAX_PAIRING_FRAME_BYTES) { "frame too large: ${payload.size}" }28 val header = ByteArray(4)29 header[0] = (payload.size ushr 24).toByte()30 header[1] = (payload.size ushr 16).toByte()31 header[2] = (payload.size ushr 8).toByte()32 header[3] = payload.size.toByte()33 out.write(header)34 out.write(payload)35 out.flush()36}3738/** Blocking read of a single length-prefixed frame. Throws on EOF / oversize. */39fun readFrame(input: InputStream): ByteArray {40 val data = if (input is DataInputStream) input else DataInputStream(input)41 val length = try {42 data.readInt()43 } catch (e: EOFException) {44 throw java.io.IOException("peer closed before frame header", e)45 }46 require(length in 0..MAX_PAIRING_FRAME_BYTES) { "invalid frame length: $length" }47 val buf = ByteArray(length)48 data.readFully(buf)49 return buf50}5152/**53 * Adapts a blocking TCP socket pair to the suspend send/receive shape the REAL54 * crypto stack expects:55 * - noiseNKInitiator / noiseNKResponder take `send: suspend (ByteArray)->Unit`56 * and `receive: suspend ()->ByteArray` (see NoiseNK.kt).57 * - ChirpPairing's [PairingTransport] interface is `send/receive/close`.58 *59 * Both the Noise handshake messages and the post-handshake encrypted payloads60 * travel as length-prefixed frames over the same socket, so one [SocketFrameIO]61 * serves the whole session. All IO hops to Dispatchers.IO.62 *63 * This implements the EXISTING [PairingTransport] interface (ChirpPairing.kt),64 * which is the contract `runPairingInitiator` / `runPairingResponder` consume —65 * so a TCP socket can drive the already-written handshake unchanged.66 */67class SocketFrameIO(68 private val input: InputStream,69 private val output: OutputStream,70 private val onClose: suspend () -> Unit = {},71) : PairingTransport {72 private val buffered = DataInputStream(input)7374 /**75 * Synchronous frame write. [ChirpSyncSession.send] is non-suspending (see76 * ChirpSyncTransport.kt), so the sync-exchange bridge needs a blocking send77 * that does NOT wrap a coroutine. Socket writes are bounded by soTimeout.78 */79 fun sendBlocking(msg: ByteArray) {80 writeFrame(output, msg)81 }8283 override suspend fun send(msg: ByteArray) = withContext(Dispatchers.IO) {84 writeFrame(output, msg)85 }8687 override suspend fun receive(): ByteArray = withContext(Dispatchers.IO) {88 readFrame(buffered)89 }9091 override suspend fun close() {92 onClose()93 }94}9596/**97 * Convenience wrappers for the post-handshake encrypted exchange. The Noise98 * [NoiseNKSession] already owns the cipher (encrypt/decrypt); these just splice99 * it onto the frame layer so call sites read clearly.100 *101 * NOTE: the existing ChirpPairing handshake calls `session.encrypt` /102 * `session.decrypt` directly around `transport.send` / `transport.receive`; the103 * post-pairing SYNC exchange (runChirpSyncInitiator/Responder) does the same via104 * a [ChirpSyncSession]. These helpers exist for the raw-frame paths in105 * PairingServer/PairingTransport that don't go through a ChirpSyncSession.106 */107suspend fun PairingTransport.sendEncrypted(session: NoiseNKSession, plaintext: ByteArray) {108 send(session.encrypt(plaintext))109}110111suspend fun PairingTransport.receiveEncrypted(session: NoiseNKSession): ByteArray {112 return session.decrypt(receive())113}