PairingFrames.kt

4.3 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-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}