-
-
Notifications
You must be signed in to change notification settings - Fork 7
Add bounded BEP 52 hash exchange wire messages #186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The requested hashes derive Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The current BEP 52 explicitly omits log2(length)-1 proof layers, while still counting them in proofLayers: https://github.com/bittorrent/bittorrent.org/blob/master/beps/bep_0052.rst#L422-L428 . The libtorrent RC_2_0 receive path also subtracts (merkle_num_layers(merkle_num_leafs(count)) - 1) when computing proof hashes: https://github.com/arvidn/libtorrent/blob/RC_2_0/src/bt_peer_connection.cpp#L1197-L1201 . Thus length=8/proofLayers=5 carries eight base hashes and three uncles. Retaining the existing calculation and its exact-size test. |
||
| } | ||
|
|
||
| /** 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()) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<PeerMessage.Unknown>( | ||
| 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<IllegalArgumentException> { PeerHashWire.decode(invalid) } | ||
| } | ||
| val valid = request() | ||
| for (size in listOf(0, 47, 49)) { | ||
| assertFailsWith<IllegalArgumentException> { | ||
| PeerHashWire.decode(PeerMessage.Unknown(21, valid.payload.copyOf(size))) | ||
| } | ||
| } | ||
| for (size in listOf(0, 32, 63, 65, 96)) { | ||
| assertFailsWith<IllegalArgumentException> { | ||
| PeerHashWire.decode(PeerMessage.Unknown(22, valid.payload + ByteArray(size))) | ||
| } | ||
| } | ||
| assertFailsWith<IllegalArgumentException> { | ||
| PeerHashWire.encode(PeerHashMessage.Hashes(PeerHashSelector(root, 0, 0, 2, 0), | ||
| ByteArray(32).toByteString())) | ||
| } | ||
| assertNull(PeerHashWire.decode(PeerMessage.Unknown(24, ByteArray(0)))) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BEP 52 permits
length = 1because one is a power of two, and this range is necessary for fetching an unpaired tail hash in layers with an odd number of entries. Rejecting it here means such valid requests cannot be encoded or decoded, preventing the complete hash layer from being exchanged.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current BEP 52 explicitly requires length to be at least two as well as a power of two (peer messages, lines 401–404): https://github.com/bittorrent/bittorrent.org/blob/master/beps/bep_0052.rst#L401-L404 . An odd tail is requested as an aligned range including canonical padding, which the proof verifier checks. Retaining the minimum of two; accepting one would contradict this protocol requirement.