Apt-P2P's Distributed Hash Table is very similar to BitTorrent's. BitTorrent uses a "distributed sloppy hash table" (DHT) for storing peer contact information for "trackerless" torrents. In effect, each peer becomes a tracker. The protocol is based on [Kademlia][10] and is implemented over UDP. [10]: http://www.cs.rice.edu/Conferences/IPTPS02/109.pdf Please note the terminology used in this document to avoid confusion. A "peer" is a client/server listening on a TCP port that implements the BitTorrent protocol. A "node" is a client/server listening on a UDP port implementing the distributed hash table protocol. The DHT is composed of nodes and stores the location of peers. BitTorrent clients include a DHT node, which is used to contact other nodes in the DHT to get the location of peers to download from using the BitTorrent protocol. ## Overview Each node has a globally unique identifier known as the "node ID." Node IDs are chosen at random from the same 160-bit space as file hashes (use SHA1 and plenty of entropy to ensure a unique ID). A "distance metric" is used to compare two node IDs or a node ID and an infohash for "closeness." Nodes must maintain a routing table containing the contact information for a small number of other nodes. The routing table becomes more detailed as IDs get closer to the node's own ID. Nodes know about many other nodes in the DHT that have IDs that are "close" to their own but have only a handful of contacts with IDs that are very far away from their own. In Kademlia, the distance metric is XOR and the result is interpreted as an unsigned integer. distance(A,B) = |A XOR B| Smaller values are closer. When a node wants to find peers for a file download, it uses the distance metric to compare the hash of the file with the IDs of the nodes in its own routing table. It then contacts the nodes it knows about with IDs closest to the file hash and asks them for the contact information of peers that currently have the file. A contacted node returns the number of peers it knows about for the file, as well as contact information of the nodes in its routing table that are closest to the hash of the file. The original node iteratively queries nodes that are closer to the target hash until it cannot find any closer nodes. The node then queries the closest found nodes to the hash of the file to get the peer contact information the nodes' know about, until it has enough (or all) values. After the download is completed, the client then inserts the peer contact information for itself onto the responding nodes with IDs closest to the hash of the file. The return value for a query for nodes close to a hash includes an opaque value known as the "token." For a node to announce that its controlling peer is sharing a file, it must present the token received from the same queried node in a recent query for nodes. When a node attempts to "announce" a shared file, the queried node checks the token against the querying node's IP address. This is to prevent malicious hosts from signing up other hosts for downloads. Since the token is merely returned by the querying node to the same node it received the token from, the implementation is not defined. Tokens must be accepted for a reasonable amount of time after they have been distributed. The current implementation uses the SHA1 hash of the IP address concatenated onto a secret that changes every 5 minutes and tokens up to 10 minutes old are accepted. ## Routing Table Every node maintains a routing table of known good nodes. The nodes in the routing table are used as starting points for queries in the DHT. Nodes from the routing table are returned in response to queries from other nodes. Not all nodes that we learn about are equal. Some are "good" and some are not. Many nodes using the DHT are able to send queries and receive responses, but are not able to respond to queries from other nodes. It is important that each node's routing table must contain only known good nodes. A good node is a node that has responded to one of our queries within the last 15 minutes. A node is also good if it has ever responded to one of our queries and has sent us a query within the last 15 minutes. After 15 minutes of inactivity, a node becomes questionable. Nodes become bad when they fail to respond to multiple queries in a row. Nodes that we know are good are given priority over nodes with unknown status. The routing table covers the entire node ID space from 0 to 2160. The routing table is subdivided into "buckets" that each cover a portion of the space. An empty table has one bucket with an ID space range of min=0, max=2160. When a node with ID "N" is inserted into the table, it is placed within the bucket that has min <= N < max. An empty table has only one bucket so any node must fit within it. Each bucket can only hold K nodes, currently eight, before becoming "full." When a bucket is full of known good nodes, no more nodes may be added unless our own node ID falls within the range of the bucket. In that case, the bucket is replaced by two new buckets each with half the range of the old bucket and the nodes from the old bucket are distributed among the two new ones. For a new table with only one bucket, the full bucket is always split into two new buckets covering the ranges 0..2159 and 2159..2160. When the bucket is full of good nodes, the new node is simply discarded. If any nodes in the bucket are known to have become bad, then one is replaced by the new node. If there are any questionable nodes in the bucket have not been seen in the last 15 minutes, the least recently seen node is pinged. If the pinged node responds then the next least recently seen questionable node is pinged until one fails to respond or all of the nodes in the bucket are known to be good. If a node in the bucket fails to respond to a ping, it is suggested to try once more before discarding the node and replacing it with a new good node. In this way, the table fills with stable long running nodes. Each bucket should maintain a "last changed" property to indicate how "fresh" the contents are. When a node in a bucket is pinged and it responds, or a node is added to a bucket, or a node in a bucket is replaced with another node, the bucket's last changed property should be updated. Buckets that have not been changed in 15 minutes should be "refreshed." This is done by picking a random ID in the range of the bucket and performing a find_nodes search on it. Nodes that are able to receive queries from other nodes usually do not need to refresh buckets often. Nodes that are not able to receive queries from other nodes usually will need to refresh all buckets periodically to ensure there are good nodes in their table when the DHT is needed. Upon inserting the first node into its routing table and when starting up thereafter, the node should attempt to find the closest nodes in the DHT to itself. It does this by issuing find_node messages to closer and closer nodes until it cannot find any closer. The routing table should be saved between invocations of the client software. ## KRPC Protocol The KRPC protocol is a simple RPC mechanism consisting of bencoded dictionaries sent over UDP. A single query packet is sent out and a single packet is sent in response. There is no retry. There are three message types: query, response, and error. For the DHT protocol, there are currently five queries: ping, join, find\_node, find\_value, get\_value, and store\_value. A KRPC message is a single dictionary with two keys common to every message and additional keys depending on the type of message. Every message has a key "t" with a string value representing a transaction ID. This transaction ID is generated by the querying node and is echoed in the response, so responses may be correlated with multiple queries to the same node. The transaction ID should be encoded as a random binary string, typically 20 bytes in length corresponding to the SHA1 hash. The other key contained in every KRPC message is "y" with a single character value describing the type of message. The value of the "y" key is one of "q" for query, "r" for response, or "e" for error. ### Queries Queries, or KRPC message dictionaries with a "y" value of "q", contain two additional keys; "q" and "a". Key "q" has a string value containing the method name of the query. Key "a" has a dictionary value containing named arguments to the query. ### Responses Responses, or KRPC message dictionaries with a "y" value of "r", contain one additional key "r". The value of "r" is a dictionary containing named return values. Response messages are sent upon successful completion of a query. ### Errors Errors, or KRPC message dictionaries with a "y" value of "e", contain one additional key "e". The value of "e" is a list. The first element is an integer representing the error code. The second element is a string containing the error message. Errors are sent when a query cannot be fulfilled. The following table describes the possible error codes:
CodeDescription
200Generic Error
201Server Error, the node encountered an error
202Malformed Packet, the packet did not match the protocol
203Method Unknown, a request to fulfill an unknown method
204Malformed Request, incorrect arguments to the method
205Invalid Token, the token has expired, get a new one
#### Example Error Packets generic error = {"t":"12345678901234567890", "y":"e", "e":[201, "A Generic Error Occurred"]} bencoded = d1:eli201e24:A Generic Error Occurrede1:t20:123456789012345678901:y1:ee ### Contact Encoding Contact information for peers is encoded as a 6-byte string. Also known as "Compact peer info", it is the 4-byte IP address in network byte order with the 2 byte port in network byte order concatenated onto the end. Contact information for nodes is encoded as a 26-byte string. Also known as "Compact node info", it is the 20-byte Node ID in network byte order with the compact peer info concatenated to the end. ## DHT Queries All queries have an "id" key and value containing the node ID of the querying node. All responses have an "id" key and value containing the node ID of the responding node. ### ping The most basic query is a ping. "q" = "ping" A ping query has a single argument, "id" the value is a 20-byte string containing the senders node ID in network byte order. The appropriate response to a ping has a single key "id" containing the node ID of the responding node. arguments: {"id": ""} response: {"id": ""} #### Example Packets ping Query = {"t":"12345678901234567890", "y":"q", "q":"ping", "a":{"id":"abcdefghij0123456789"}} bencoded = d1:ad2:id20:abcdefghij0123456789e1:q4:ping1:t20:123456789012345678901:y1:qe Response = {"t":"12345678901234567890", "y":"r", "r": {"id":"mnopqrstuvwxyz123456"}} bencoded = d1:rd2:id20:mnopqrstuvwxyz123456e1:t20:123456789012345678901:y1:re ### join join is identical to ping, except in the response provided. It is intended to be sent only to bootstrap nodes during the process of a new peer joining the system. The response to a "q" = "join" query has two additional responses, a key "ip\_addr" whose value is the bootstrap node's understanding of the peer's IP address, and a key "port" whose value is the port number that the bootstrap node received the request from. arguments: {"id": ""} response: {"id": "", "ip_addr": "", "port": ""} #### Example Packets join Query = {"t":"12345678901234567890", "y":"q", "q":"join", "a":{"id":"abcdefghij0123456789"}} bencoded = d1:ad2:id20:abcdefghij0123456789e1:q4:join1:t20:123456789012345678901:y1:qe Response = {"t":"12345678901234567890", "y":"r", "r": {"id":"mnopqrstuvwxyz123456", "ip_addr":"123.123.123.123", "port":12345}} bencoded = d1:rd2:id20:mnopqrstuvwxyz1234567:ip_addr15:123.123.123.1234:porti12345ee1:t20:123456789012345678901:y1:re ### find\_node Find node is used to find the contact information for a node given its ID. "q" = "find\_node" A find_node query has two arguments, "id" containing the node ID of the querying node, and "target" containing the ID of the node sought by the querier. When a node receives a find\_node query, it should respond with a key "nodes" and value of list of strings containing the compact node info for the target node or the K (8) closest good nodes in its own routing table. A "token" key is also included in the return value. The token value is a required argument for a future store\_value query. The token value should be a short binary string. arguments: {"id": "", "target": ""} response: {"id": "", "nodes": [""], "token": ""} #### Example Packets find_node Query = {"t":"12345678901234567890", "y":"q", "q":"find_node", "a": {"id":"abcdefghij0123456789", "target":"mnopqrstuvwxyz123456"}} bencoded = d1:ad2:id20:abcdefghij01234567896:target20:mnopqrstuvwxyz123456e1:q9:find_node1:t20:123456789012345678901:y1:qe Response = {"t":"12345678901234567890", "y":"r", "r": {"id":"0123456789abcdefghij", "nodes": ["12345678901234567890def456"], "token":"aoeusnth"}} bencoded = d1:rd2:id20:0123456789abcdefghij5:nodesl26:12345678901234567890def4565e:token8:aoeusnthe1:t20:123456789012345678901:y1:re ### find\_value Find values associated with a file hash. "q" = "find\_value" A find\_value query has two arguments, "id" containing the node ID of the querying node, and "key" containing the hash of the file. The queried node returns in a key "num" which is the number of values it has for that key. A key "nodes" is also returned containing compact node info of the K nodes in the queried nodes routing table closest to the hash supplied in the query. arguments: {"id": "", "key": "<20-byte hash of target file>"} response: {"id": "", "num": , "nodes": [""]} #### Example Packets find_value Query = {"t":"12345678901234567890", "y":"q", "q":"find_value", "a": {"id":"abcdefghij0123456789", "key":"mnopqrstuvwxyz123456"}} bencoded = d1:ad2:id20:abcdefghij01234567893:key20:mnopqrstuvwxyz123456e1:q10:find_value1:t20:123456789012345678901:y1:qe Response = {"t":"12345678901234567890", "y":"r", "r": {"id":"abcdefghij0123456789", "num": 23, "nodes": ["12345678901234567890def456"]}} bencoded = d1:rd2:id20:abcdefghij01234567895:nodesl26:12345678901234567890def456e3:numi23ee1:t20:123456789012345678901:y1:re ### get\_value Get values associated with a file hash. "q" = "get\_value" A get\_value query has three arguments, "id" containing the node ID of the querying node, "num" containing the maximum number of values to return, and "key" containing the hash of the file. Setting "num" to 0 indicates that the querying node wants as many values as possible. The queried node returns any values for the hash in a key "values" as a list of strings. The queried node may not return as many values as requested, as it may have a maximum number or the UDP packet size may limit the results. The order of the returned values should be shuffled for each peer. See the next section for a description of the possible stored values. arguments: {"id": "", "key": "<20-byte hash of target file>", "num": } response: {"id": "", "values": ["", ""]} #### Example Packets get_value Query = {"t":"12345678901234567890", "y":"q", "q":"get_value", "a": {"id":"abcdefghij0123456789", "key":"mnopqrstuvwxyz123456", "num":10}} bencoded = d1:ad2:id20:abcdefghij01234567893:key20:mnopqrstuvwxyz1234563:numi10ee1:q9:get_value1:t20:123456789012345678901:y1:qe Response = {"t":"12345678901234567890", "y":"r", "r": {"id":"abcdefghij0123456789", "values": ["d1:c6:456abc", "d1:c6:def456e"]}} bencoded = d1:rd2:id20:abcdefghij01234567896:valuesl6:axje.u6:idhtnmee1:t20:123456789012345678901:y1:re ### store\_value Store a value in the DHT. store\_value has four arguments: "id" containing the node ID of the querying node, "key" containing the hash of the file, "value" is a string containing the value to be stored, and the "token" received in response to a previous find_node query. The queried node must verify that the token was previously sent to the same IP address as the querying node. See the next section for a description of the possible stored strings. The queried node should store the contact information of the querying node under the file hash in its store of peer contact information. arguments: {"id" : "", "key" : "<20-byte hash of target file>", "value" : "", "token" : ""} response: {"id" : ""} #### Example Packets store_value Query = {"t":"12345678901234567890", "y":"q", "q":"store_value", "a": {"id":"abcdefghij0123456789", "key":"mnopqrstuvwxyz123456", "value":"d1:c6:def456e", "token":"aoeusnth"}} bencoded = d1:ad2:id20:abcdefghij01234567893:key20:mnopqrstuvwxyz1234565:token8:aoeusnth5:value13:d1:c6:def456ee1:q13:store_value1:t20:123456789012345678901:y1:qe Response = {"t":"12345678901234567890", "y":"r", "r": {"id":"mnopqrstuvwxyz123456"}} bencoded = d1:rd2:id20:mnopqrstuvwxyz123456e1:t20:123456789012345678901:y1:re ## Stored Values All of the values stored and retrieved in the previous section's description of the "get\_value" and "store\_value" DHT queries are strings. Currently, all stored values are bencoded strings of a dictionary containing many possibilities. In the simplest possible implementation, the dictionary will have a single key "c", whose value is the 6-byte compact contact information for the peer providing the file. All other possibilities build on this simple model. stored value = {"c":"def456"} bencoded = d1:c6:def456e ### Piece Hashes In order to support the efficient downloading of large files from multiple peers, hashes of smaller pieces of the large files are necessary. #### Piece Size To accomplish this, a piece size must be chosen that is consistent across all clients, and is no larger than some acceptable maximum piece size. An optimal piece size would have all the pieces be the same size, which would be less than or equal to the maximum piece size, to prevent the last piece from being very small. However, downloading occurs by requesting smaller *chunks* of a piece, so choosing a piece size that is a multiple of the chunk size will prevent having to request very small chunks. Therefore, the following formula, in which `size` is the size of the file, `MAX_SIZE` is the maximum piece size, and `CHUNK_SIZE` is the chunk size, will give the number of pieces and optimal piece size: n = 1 + floor((size - 1) / MAX_SIZE) optimal = max(MAX_SIZE/2, ceil((size/n)/CHUNK_SIZE)*CHUNK_SIZE) There will then be `n-1` pieces of size `optimal`, and 1 piece of size `size - (n-1)*optimal`. The maximum piece size is 512 KiB (524288 bytes), and the chunk size is 16 KiB (16384 bytes). So, for example, for a file of size 1234567, there will be 2 pieces of size 425984 followed by a single piece of size 382599. #### Piece Hash Storage Files larger than the maximum piece size will require multiple pieces. Each piece of the file will have to be hashed to get these piece hashes. Once hashed, the piece hashes will be concatenated and stored in a binary string whose length is a multiple of 20 (the length of the SHA1 hash). #### Storage in the DHT Storage of the piece hashes so that peers accessing the DHT can retrieve them will depend on the number of pieces. This is due to the limitation on the length of a UDP packet to prevent fragmentation, which prevents returning very long strings, especially when there are multiple values for a single key. This leads to 4 scenarios: 1. The file requires only a single piece. In this case, no piece-hashing is needed, so store only the basic dictionary with the "c" key. stored value = {"c":"def456"} bencoded = d1:c6:def456e 2. The file is 2 to 4 pieces in length. The piece hash strings are short enough to store directly with the contact information. A key "t" is added to the stored dictionary which is a dictionary containing a key "t" which has as a value the concatenated string of piece hashes. stored value = {"c":"def456", "t":{"t":"abcdefghij0123456789..."}} bencoded = d1:c6:def4561:td1:t23:abcdefghij0123456789...ee 3. The file is 5 to 70 pieces in length. The piece hash strings are too long to store with the contact information, as that would limit the node to returning very few values. Instead, hash the piece hash string using SHA1, and store that string with a key "h" in the stored dictionary. Then, store the concatenated piece hash string in the DHT using the hash of the string as the key. There, the stored dictionary will contain only a single key "t", whose value will be the concatenated string of piece hashes. stored value = {"c":"def456", "h":"12345678901234567890"} bencoded = d1:c6:def4561:h20:12345678901234567890e stored value at key 12345678901234567890 = {"t":"abcdefghij0123456789..."} bencoded = d1:t23:abcdefghij0123456789...e 4. The file is more than 70 pieces in length. The piece hash string is now too long for a single UDP packet, and so must be stored outside the DHT. Again, hash the piece hash string using SHA1, and store that string with a key "l" in the stored dictionary. Then, respond to any peer download requests for that hash with a bencoded dictionary containing a key "t" whose value is the concatenated string of piece hashes. stored value = {"c":"def456", "l":"12345678901234567890"} bencoded = d1:c6:def4561:l20:12345678901234567890e