Skip to content
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

BEP-42: DHT security extension #53

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
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
122 changes: 107 additions & 15 deletions client.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ var once = require('once')
var os = require('os')
var parallel = require('run-parallel')
var string2compact = require('string2compact')
var ip = require('ip')
var crc32c = require('fast-crc32c')

var BOOTSTRAP_NODES = [
'router.bittorrent.com:6881',
Expand All @@ -31,6 +33,12 @@ var ROTATE_INTERVAL = 5 * 60 * 1000 // rotate secrets every 5 minutes
var SECRET_ENTROPY = 160 // entropy of token secrets
var SEND_TIMEOUT = 2000

var VERIFY_NODE_ID = false

// IP masks for node ID generation
var DHT_SEC_IPV4_MASK = new Buffer('030f3fff', 'hex')
var DHT_SEC_IPV6_MASK = new Buffer('0103070f1f3f7fff', 'hex')

var MESSAGE_TYPE = module.exports.MESSAGE_TYPE = {
QUERY: 'q',
RESPONSE: 'r',
Expand Down Expand Up @@ -67,12 +75,8 @@ function DHT (opts) {
if (!debug.enabled) self.setMaxListeners(0)

if (!opts) opts = {}

self.nodeId = idToBuffer(opts.nodeId || hat(160))
self.ipv = opts.ipv || 4

self._debug('new DHT %s', idToHexString(self.nodeId))

self.ready = false
self.listening = false
self._binding = false
Expand All @@ -90,16 +94,6 @@ function DHT (opts) {
announce_peer: self._onAnnouncePeer
}

/**
* Routing table
* @type {KBucket}
*/
self.nodes = new KBucket({
localNodeId: self.nodeId,
numberOfNodesPerKBucket: K,
numberOfNodesToPing: MAX_CONCURRENCY
})

/**
* Cache of routing tables used during a lookup. Saved in this object so we can access
* each node's unique token for announces later.
Expand All @@ -126,6 +120,22 @@ function DHT (opts) {
self.socket.on('listening', self._onListening.bind(self))
self.socket.on('error', function () {}) // throw away errors

// TODO make this work
// self.nodeId = generateNodeId(self.socket.address().address)
self.nodeId = idToBuffer(opts.nodeId || hat(160))

self._debug('new DHT %s', idToHexString(self.nodeId))

/**
* Routing table
* @type {KBucket}
*/
self.nodes = new KBucket({
localNodeId: self.nodeId,
numberOfNodesPerKBucket: K,
numberOfNodesToPing: MAX_CONCURRENCY
})

self._rotateSecrets()
self._rotateInterval = setInterval(self._rotateSecrets.bind(self), ROTATE_INTERVAL)
self._rotateInterval.unref && self._rotateInterval.unref()
Expand Down Expand Up @@ -266,14 +276,17 @@ DHT.prototype.destroy = function (cb) {
}

/**
* Add a DHT node to the routing table.
* Add a DHT node to the routing table unless the nodeID doesn't match
* their address and we're enforcing nodeID security.
Copy link

Choose a reason for hiding this comment

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

If you read the enforcement section of BEP42 you will notice that it only mentions lookups, not routing table maintenance.

I.e. as written it does not to exclude nodes from the overlay itself, only ignores them as store-targets to avoid eclipse attacks.

* @param {string} addr
* @param {string|Buffer} nodeId
* @param {string=} from addr
*/
DHT.prototype.addNode = function (addr, nodeId, from) {
var self = this
if (self._destroyed) return
if (VERIFY_NODE_ID && !isValidNodeId(addr, nodeId)) return

nodeId = idToBuffer(nodeId)

if (self._addrIsSelf(addr)) {
Expand Down Expand Up @@ -701,6 +714,9 @@ DHT.prototype._send = function (addr, message, cb) {
return
}

// TODO convert to binary? it's bencoded anyway
message.ip = addr

// self._debug('send %s to %s', JSON.stringify(message), addr)
message = bencode.encode(message)
self.socket.send(message, 0, message.length, port, host, cb)
Expand Down Expand Up @@ -1114,6 +1130,14 @@ DHT.prototype._debug = function () {
debug.apply(null, args)
}

DHT.idPrefixMatches = idPrefixMatches

DHT.isValidNodeId = isValidNodeId

DHT.generateNodeId = generateNodeId

DHT.calculateIdPrefix = calculateIdPrefix

/**
* Parse saved string
* @param {Array.<Object>} nodes
Expand Down Expand Up @@ -1217,3 +1241,71 @@ function idToHexString (id) {
function sha1 (buf) {
return crypto.createHash('sha1').update(buf).digest()
}

// Return true iff first 21 bits of two buffers match
function idPrefixMatches (a, b) {
a = idToBuffer(a)
b = idToBuffer(b)
return a[0] === b[0]
&& a[1] === b[1]
&& (a[2] & 0xf8) === (b[2] & 0xf8)
}

// Validate nodeId given ip address
function isValidNodeId (addr, nodeId) {
// TODO should handle more types of inputs
addr = addr.split(':')[0]
var id = idToBuffer(nodeId)
var r = id[id.length - 1] & 0x7
var prefix = calculateIdPrefix(addr, r)

return idPrefixMatches(prefix, id)
}

// Generate node id from an IP address
function generateNodeId (addr) {
addr = addr.split(':')[0]
var randByte = crypto.randomBytes(1)[0]
var r = randByte & 0x7
var prefix = calculateIdPrefix(addr, r)

var id = new Buffer(20)

// set prefix bits 0-21
for (var i = 0; i < 3; i++) {
id[i] = prefix[i]
}

// set random bytes 3-20
var middle = crypto.randomBytes(17)

id[2] |= middle[0] & 0x7

for (i = 1; i < 17; i++) {
id[i + 2] = middle[i]
}

id[19] = randByte
return id
}

// Calculate 21-bit node ID prefix as a 3-byte buffer.
// See http://www.bittorrent.org/beps/bep_0042.html
function calculateIdPrefix (addr, r) {
var buf = ip.toBuffer(addr)
var mask = buf.length === 4 ? DHT_SEC_IPV4_MASK : DHT_SEC_IPV6_MASK
var n = Math.min(buf.length, mask.length)
var arg = new Buffer(n)

for (var i = 0; i < n; i++) {
arg[i] = buf[i] & mask[i]
}

arg[0] |= r << 5

var crc = crc32c.calculate(arg)
var ret = new Buffer(4)
ret.writeUInt32BE(crc, 0)
ret[2] = ret[2] & 0xf8
return ret.slice(0, 3)
}
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@
"k-bucket": "^0.4.2",
"once": "^1.3.1",
"run-parallel": "^1.0.0",
"string2compact": "^1.1.1"
"string2compact": "^1.1.1",
"ip": "^0.3.2",
"fast-crc32c": "^0.1.3"
},
"devDependencies": {
"ip": "^0.3.0",
"standard": "^2.0.0",
"tape": "^2.12.3"
"tape": "^2.12.3",
"chance": "^0.7.3"
},
"homepage": "http://webtorrent.io",
"keywords": [
Expand Down
95 changes: 70 additions & 25 deletions test/basic.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,7 @@
var common = require('./common')
var DHT = require('../')
var test = require('tape')

test('explicitly set nodeId', function (t) {
var nodeId = common.randomId()

var dht = new DHT({
nodeId: nodeId,
bootstrap: false
})

common.failOnWarningOrError(t, dht)

t.equal(dht.nodeId, nodeId)
t.end()
})
var Chance = require('chance')

test('`ping` query send and response', function (t) {
t.plan(2)
Expand All @@ -37,23 +24,25 @@ test('`ping` query send and response', function (t) {

test('`find_node` query for exact match (with one in table)', function (t) {
t.plan(3)
var targetNodeId = common.randomId()

var targetAddr = '255.255.255.255:6969'
var targetNodeId = DHT.generateNodeId(targetAddr)

var dht1 = new DHT({ bootstrap: false })
var dht2 = new DHT({ bootstrap: false })

common.failOnWarningOrError(t, dht1)
common.failOnWarningOrError(t, dht2)

dht1.addNode('255.255.255.255:6969', targetNodeId)
dht1.addNode(targetAddr, targetNodeId)

dht1.listen(function (port) {
dht2._sendFindNode('127.0.0.1:' + port, targetNodeId, function (err, res) {
t.error(err)
t.deepEqual(res.id, dht1.nodeId)
t.deepEqual(
res.nodes.map(function (node) { return node.addr }),
[ '255.255.255.255:6969', '127.0.0.1:' + dht2.port ]
[ targetAddr, '127.0.0.1:' + dht2.port ]
)

dht1.destroy()
Expand All @@ -70,9 +59,13 @@ test('`find_node` query (with many in table)', function (t) {
common.failOnWarningOrError(t, dht1)
common.failOnWarningOrError(t, dht2)

dht1.addNode('1.1.1.1:6969', common.randomId())
dht1.addNode('10.10.10.10:6969', common.randomId())
dht1.addNode('255.255.255.255:6969', common.randomId())
var addrs = ['1.1.1.1:6969',
'10.10.10.10:6969',
'255.255.255.255:6969']

addrs.forEach(function (addr) {
dht1.addNode(addr, DHT.generateNodeId(addr))
})

dht1.listen(function (port) {
var targetNodeId = common.randomId()
Expand All @@ -81,8 +74,7 @@ test('`find_node` query (with many in table)', function (t) {
t.deepEqual(res.id, dht1.nodeId)
t.deepEqual(
res.nodes.map(function (node) { return node.addr }).sort(),
[ '1.1.1.1:6969', '10.10.10.10:6969', '127.0.0.1:' + dht2.port,
'255.255.255.255:6969' ]
addrs.concat(['127.0.0.1:' + dht2.port]).sort()
)

dht1.destroy()
Expand All @@ -99,8 +91,10 @@ test('`get_peers` query to node with *no* peers in table', function (t) {
common.failOnWarningOrError(t, dht1)
common.failOnWarningOrError(t, dht2)

dht1.addNode('1.1.1.1:6969', common.randomId())
dht1.addNode('2.2.2.2:6969', common.randomId())
var addrs = ['1.1.1.1:6969', '2.2.2.2:6969']

dht1.addNode(addrs[0], DHT.generateNodeId(addrs[0]))
dht1.addNode(addrs[1], DHT.generateNodeId(addrs[1]))

dht1.listen(function (port) {
var targetInfoHash = common.randomId()
Expand All @@ -110,7 +104,7 @@ test('`get_peers` query to node with *no* peers in table', function (t) {
t.ok(Buffer.isBuffer(res.token))
t.deepEqual(
res.nodes.map(function (node) { return node.addr }).sort(),
[ '1.1.1.1:6969', '127.0.0.1:' + dht2.port, '2.2.2.2:6969' ]
addrs.concat(['127.0.0.1:' + dht2.port]).sort()
)

dht1.destroy()
Expand Down Expand Up @@ -200,3 +194,54 @@ test('`announce_peer` query gets ack response', function (t) {
})
})
})

// test vectors at http://www.bittorrent.org/beps/bep_0042.html
test('test vectors for node ID generation', function (t) {
t.plan(5)

var ips = ['124.31.75.21',
'21.75.31.124',
'65.23.51.170',
'84.124.73.14',
'43.213.53.83']

var randoms = [1, 86, 22, 65, 90]

var ids = ['5fbfbff10c5d6a4ec8a88e4c6ab4c28b95eee401',
'5a3ce9c14e7a08645677bbd1cfe7d8f956d53256',
'a5d43220bc8f112a3d426c84764f8c2a1150e616',
'1b0321dd1bb1fe518101ceef99462b947a01ff41',
'e56f6cbf5b7c4be0237986d5243b87aa6d51305a']

for (var i = 0; i < 5; i++) {
var prefix = DHT.calculateIdPrefix(ips[i], randoms[i] & 0x7)
t.ok(DHT.idPrefixMatches(prefix, ids[i]),
'expected: ' + prefix.toString('hex') +
'actual id: ' + ids[i].toString('hex'))
}
})

test('generate and validate node IDs for IPv4 and IPv6', function (t) {
var numAddresses = 100
var idsPerAddr = 100
var chance = new Chance()

t.plan(1)

var idsValid = []

for (var i = 0; i < numAddresses; i++) {
var addr4 = chance.ip()
var addr6 = chance.ipv6()

for (var j = 0; j < idsPerAddr; j++) {
var id4 = DHT.generateNodeId(addr4)
idsValid.push(DHT.isValidNodeId(addr4, id4))

var id6 = DHT.generateNodeId(addr6)
idsValid.push(DHT.isValidNodeId(addr6, id6))
}
}

t.ok(idsValid.every(function (v) { return v }))
})