From c238102bb857c104083cfb6ba7d6b1d1a3d220de Mon Sep 17 00:00:00 2001 From: Lin Zhang Date: Sun, 13 Sep 2026 00:20:36 +0800 Subject: [PATCH] Add bounded BEP 52 hash exchange wire messages --- docs/design/torrent-v2-integrity.md | 15 ++++ .../com/linroid/ketch/torrent/PeerHashWire.kt | 82 +++++++++++++++++++ .../linroid/ketch/torrent/PeerHashWireTest.kt | 76 +++++++++++++++++ 3 files changed, 173 insertions(+) create mode 100644 library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/PeerHashWire.kt create mode 100644 library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/PeerHashWireTest.kt diff --git a/docs/design/torrent-v2-integrity.md b/docs/design/torrent-v2-integrity.md index fc4ee700b..f2843f678 100644 --- a/docs/design/torrent-v2-integrity.md +++ b/docs/design/torrent-v2-integrity.md @@ -273,3 +273,18 @@ These executed process-exit cases extend the injected-I/O tests. They do not pro ordering, directory-entry durability, physical mobile lifecycle behavior, or the still-pending v2 runtime/network integration. The iOS simulator task requires `-PenableIosSimulatorTests=true`; a successful Gradle build without that flag skips simulator execution and is not test evidence. + +### BEP 52 hash-message wire codec + +`PeerHashWire` encodes and decodes hash request (21), hashes (22), and hash reject (23) payloads +through the existing bounded `PeerWire` frames. It preserves full file-root hashes and unsigned +32-bit indices, requires aligned power-of-two ranges, and checks exact response lengths before +copying hash bytes. The response count omits the first `log2(length)-1` proof layers while retaining +the requested proof-layer count in the selector, as specified by +[BEP 52](https://raw.githubusercontent.com/bittorrent/bittorrent.org/master/beps/bep_0052.rst). + +This adapter limits requests to 512 hashes, following BEP 52's recommended maximum, and bounds +layer fields to 63. Authenticated file-tree bounds, supported base-layer policy, outstanding-request +correlation, buffer admission, response proof authentication and hash serving remain required +connection-handler work. The codec alone does not authorize any hashes or payload progress. +It is separate from the v1 runtime: later v2 negotiation must explicitly route these frames to it. diff --git a/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/PeerHashWire.kt b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/PeerHashWire.kt new file mode 100644 index 000000000..069922c01 --- /dev/null +++ b/library/torrent/src/commonMain/kotlin/com/linroid/ketch/torrent/PeerHashWire.kt @@ -0,0 +1,82 @@ +package com.linroid.ketch.torrent + +import okio.Buffer +import okio.ByteString + +/** BEP 52 hash exchange. These messages are interpreted only on a negotiated v2 connection. */ +internal sealed interface PeerHashMessage { + data class Request(val selector: PeerHashSelector) : PeerHashMessage + data class Hashes(val selector: PeerHashSelector, val hashes: ByteString) : PeerHashMessage + data class Reject(val selector: PeerHashSelector) : PeerHashMessage +} + +/** File-root identity and coordinates; file-specific tree bounds require authenticated metadata. */ +internal data class PeerHashSelector( + val root: ByteString, + val baseLayer: Int, + val index: Long, + val length: Int, + val proofLayers: Int, +) { + init { + require(root.size == 32) + require(baseLayer in 0..63 && proofLayers in 0..63 && baseLayer + proofLayers <= 63) + require(length in 2..512 && length and (length - 1) == 0) + require(index in 0..0xffff_ffffL && index % length == 0L) + require(index + length <= 0x1_0000_0000L) + } + + // The first log2(length)-1 proof layers are counted but omitted from the response. + val hashCount: Int get() = length + maxOf(0, proofLayers - length.countTrailingZeroBits() + 1) +} + +/** Adapts bounded PeerWire unknown frames without enabling v2 semantics in the v1 runtime. */ +internal object PeerHashWire { + fun decode(message: PeerMessage.Unknown): PeerHashMessage? { + if (message.id !in 21..23) return null + require(message.payload.size in 48 until PeerWire.MAX_FRAME_SIZE) + val input = Buffer().write(message.payload) + val root = input.readByteString(32) + fun layer(): Int { + val value = input.readInt() + require(value in 0..63) + return value + } + val base = layer() + val index = input.readInt().toLong() and 0xffff_ffffL + val length = input.readInt() + val proof = layer() + val selector = PeerHashSelector(root, base, index, length, proof) + return when (message.id) { + 21, 23 -> { + require(input.exhausted()) { "Unexpected hash request/reject payload" } + if (message.id == 21) PeerHashMessage.Request(selector) else + PeerHashMessage.Reject(selector) + } + else -> { + require(input.size == selector.hashCount * 32L) { "Wrong hash response size" } + PeerHashMessage.Hashes(selector, input.readByteString()) + } + } + } + + fun encode(message: PeerHashMessage): PeerMessage.Unknown { + val selector = when (message) { + is PeerHashMessage.Request -> message.selector + is PeerHashMessage.Hashes -> message.selector + is PeerHashMessage.Reject -> message.selector + } + val id = when (message) { + is PeerHashMessage.Request -> 21 + is PeerHashMessage.Hashes -> 22 + is PeerHashMessage.Reject -> 23 + } + if (message is PeerHashMessage.Hashes) { + require(message.hashes.size == selector.hashCount * 32) { "Wrong hash response size" } + } + val out = Buffer().write(selector.root).writeInt(selector.baseLayer) + .writeInt(selector.index.toInt()).writeInt(selector.length).writeInt(selector.proofLayers) + if (message is PeerHashMessage.Hashes) out.write(message.hashes) + return PeerMessage.Unknown(id, out.readByteArray()) + } +} diff --git a/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/PeerHashWireTest.kt b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/PeerHashWireTest.kt new file mode 100644 index 000000000..b0d8222d0 --- /dev/null +++ b/library/torrent/src/commonTest/kotlin/com/linroid/ketch/torrent/PeerHashWireTest.kt @@ -0,0 +1,76 @@ +package com.linroid.ketch.torrent + +import okio.Buffer +import okio.ByteString.Companion.toByteString +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +class PeerHashWireTest { + private val root = ByteArray(32) { it.toByte() }.toByteString() + + @Test + fun requestAndRejectUseExactBigEndianWireFieldsIncludingUnsignedIndex() { + val selector = PeerHashSelector(root, 1, 0xffff_fe00L, 512, 12) + val fields = root.toByteArray() + byteArrayOf(0, 0, 0, 1, + -1, -1, -2, 0, 0, 0, 2, 0, 0, 0, 0, 12) + for ((id, message) in listOf(21 to PeerHashMessage.Request(selector), + 23 to PeerHashMessage.Reject(selector))) { + val encoded = PeerWire.encode(PeerHashWire.encode(message)) + assertContentEquals(byteArrayOf(0, 0, 0, 49, id.toByte()) + fields, encoded) + val outer = assertIs( + PeerWire.decode(encoded.copyOfRange(4, encoded.size))) + assertEquals(message, PeerHashWire.decode(outer)) + } + } + + @Test + fun responseOmitsCoveredProofLayersButPreservesTheirCountInTheSelector() { + // Eight base hashes cover the first two requested proof layers; only three uncles follow. + val selector = PeerHashSelector(root, 0, 8, 8, 5) + val hashes = ByteArray(11 * 32) { (it * 17).toByte() }.toByteString() + val message = PeerHashMessage.Hashes(selector, hashes) + val encoded = PeerHashWire.encode(message) + assertEquals(22, encoded.id) + assertEquals(48 + 11 * 32, encoded.payload.size) + assertEquals(message, PeerHashWire.decode(encoded)) + for (proof in 0..2) { + val covered = selector.copy(proofLayers = proof) + assertEquals(8, covered.hashCount) + assertEquals(PeerHashMessage.Hashes(covered, ByteArray(256).toByteString()), + PeerHashWire.decode(PeerHashWire.encode( + PeerHashMessage.Hashes(covered, ByteArray(256).toByteString())))) + } + } + + @Test + fun malformedCoordinatesAndResponseSizesAreRejectedBeforeHashAllocation() { + fun request(base: Int = 0, index: Int = 0, length: Int = 2, proof: Int = 0) = + PeerMessage.Unknown(21, Buffer().write(root).writeInt(base).writeInt(index) + .writeInt(length).writeInt(proof).readByteArray()) + for (invalid in listOf(request(base = -1), request(base = 64), request(proof = 64), + request(base = 63, proof = 1), request(index = 1), request(length = 1), + request(length = 3), request(length = 1024), request(length = Int.MIN_VALUE))) { + assertFailsWith { PeerHashWire.decode(invalid) } + } + val valid = request() + for (size in listOf(0, 47, 49)) { + assertFailsWith { + PeerHashWire.decode(PeerMessage.Unknown(21, valid.payload.copyOf(size))) + } + } + for (size in listOf(0, 32, 63, 65, 96)) { + assertFailsWith { + PeerHashWire.decode(PeerMessage.Unknown(22, valid.payload + ByteArray(size))) + } + } + assertFailsWith { + PeerHashWire.encode(PeerHashMessage.Hashes(PeerHashSelector(root, 0, 0, 2, 0), + ByteArray(32).toByteString())) + } + assertNull(PeerHashWire.decode(PeerMessage.Unknown(24, ByteArray(0)))) + } +}