Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions docs/design/torrent-v2-integrity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept single-hash request ranges

BEP 52 permits length = 1 because 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

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.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Omit all proof layers derived from the requested range

The requested hashes derive log2(length) proof levels, not log2(length) - 1. For example, a request with length = 8 and proofLayers = 5 carries 8 base hashes plus only 2 proof hashes, but this calculation requires 11 hashes instead of 10; consequently valid peer responses are rejected and encoded responses contain an extra hash that other BEP 52 implementations will not expect.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

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 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))))
}
}
Loading