From 10a4cf0337f19c6285b8033c5a709b3b1d153e3b Mon Sep 17 00:00:00 2001 From: Friedel Ziegelmayer Date: Mon, 13 Jun 2016 19:10:38 +0200 Subject: [PATCH] feat(secio): implement with pull-streams, ensure interop with go --- README.md | 52 ++++++++-- package.json | 26 ++--- src/etm.js | 53 ++++++----- src/handshake/crypto.js | 163 ++++++++++++++++++++++++++++++++ src/handshake/exchange.js | 99 +++---------------- src/handshake/finish.js | 49 +++++++--- src/handshake/index.js | 22 +++-- src/handshake/propose.js | 134 +++----------------------- src/{ => handshake}/secio.proto | 0 src/index.js | 111 +++------------------- src/state.js | 67 +++++++++++++ src/support.js | 55 +++++++++++ test/index.spec.js | 110 +++++++-------------- 13 files changed, 493 insertions(+), 448 deletions(-) create mode 100644 src/handshake/crypto.js rename src/{ => handshake}/secio.proto (100%) create mode 100644 src/state.js diff --git a/README.md b/README.md index 3b5a56c..9096fa5 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,14 @@ [![](https://img.shields.io/badge/project-IPFS-blue.svg?style=flat-square)](http://ipfs.io/) [![](https://img.shields.io/badge/freenode-%23ipfs-blue.svg?style=flat-square)](http://webchat.freenode.net/?channels=%23ipfs) [![standard-readme compliant](https://img.shields.io/badge/standard--readme-OK-green.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) -[![Coverage Status](https://coveralls.io/repos/github/ipfs/js-libp2p-secio/badge.svg?branch=master)](https://coveralls.io/github/ipfs/js-libp2p-secio?branch=master) -[![Travis CI](https://travis-ci.org/ipfs/js-libp2p-secio.svg?branch=master)](https://travis-ci.org/ipfs/js-libp2p-secio) -[![Circle CI](https://circleci.com/gh/ipfs/js-libp2p-secio.svg?style=svg)](https://circleci.com/gh/ipfs/js-libp2p-secio) -[![Dependency Status](https://david-dm.org/ipfs/js-libp2p-secio.svg?style=flat-square)](https://david-dm.org/ipfs/js-libp2p-secio) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/feross/standard) +[![Coverage Status](https://coveralls.io/repos/github/libp2p/js-libp2p-secio/badge.svg?branch=master)](https://coveralls.io/github/libp2p/js-libp2p-secio?branch=master) +[![Travis CI](https://travis-ci.org/libp2p/js-libp2p-secio.svg?branch=master)](https://travis-ci.org/libp2p/js-libp2p-secio) +[![Circle CI](https://circleci.com/gh/libp2p/js-libp2p-secio.svg?style=svg)](https://circleci.com/gh/libp2p/js-libp2p-secio) +[![Dependency Status](https://david-dm.org/libp2p/js-libp2p-secio.svg?style=flat-square)](https://david-dm.org/libp2p/js-libp2p-secio) [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/feross/standard) > Secio implementation in JavaScript -This repo contains the JavaScript implementation of secio, an encryption protocol used in libp2p. This is based on this [go implementation](https://github.com/ipfs/go-libp2p-secio). +This repo contains the JavaScript implementation of secio, an encryption protocol used in libp2p. This is based on this [go implementation](https://github.com/libp2p/go-libp2p-secio). ## Table of Contents @@ -24,17 +24,55 @@ This repo contains the JavaScript implementation of secio, an encryption protoco ## Install ```sh -npm install --save libp2p-secio +npm install libp2p-secio ``` ## Usage ```js -const libp2pSecio = require('libp2p-secio') +const secio = require('libp2p-secio') ``` ## API +### `SecureSession` + +#### `constructor(id, key, insecure)` + +- `id: PeerId` - The id of the node. +- `key: RSAPrivateKey` - The private key of the node. +- `insecure: PullStream` - The insecure connection. + +### `.secure` + +Returns the `insecure` connection provided, wrapped with secio. This is a pull-stream. + +### This module uses `pull-streams` + +We expose a streaming interface based on `pull-streams`, rather then on the Node.js core streams implementation (aka Node.js streams). `pull-streams` offers us a better mechanism for error handling and flow control guarantees. If you would like to know more about why we did this, see the discussion at this [issue](https://github.com/ipfs/js-ipfs/issues/362). + +You can learn more about pull-streams at: + +- [The history of Node.js streams, nodebp April 2014](https://www.youtube.com/watch?v=g5ewQEuXjsQ) +- [The history of streams, 2016](http://dominictarr.com/post/145135293917/history-of-streams) +- [pull-streams, the simple streaming primitive](http://dominictarr.com/post/149248845122/pull-streams-pull-streams-are-a-very-simple) +- [pull-streams documentation](https://pull-stream.github.io/) + +#### Converting `pull-streams` to Node.js Streams + +If you are a Node.js streams user, you can convert a pull-stream to a Node.js stream using the module [`pull-stream-to-stream`](https://github.com/dominictarr/pull-stream-to-stream), giving you an instance of a Node.js stream that is linked to the pull-stream. For example: + +```js +const pullToStream = require('pull-stream-to-stream') + +const nodeStreamInstance = pullToStream(pullStreamInstance) +// nodeStreamInstance is an instance of a Node.js Stream +``` + +To learn more about this utility, visit https://pull-stream.github.io/#pull-stream-to-stream. + + + ## Contribute Feel free to join in. All welcome. Open an [issue](https://github.com/ipfs/js-libp2p-secio/issues)! diff --git a/package.json b/package.json index 65ab743..368b244 100644 --- a/package.json +++ b/package.json @@ -25,27 +25,27 @@ "author": "Friedel Ziegelmayer ", "license": "MIT", "dependencies": { - "async-buffered-reader": "^1.2.1", "debug": "^2.2.0", - "duplexify": "^3.4.3", - "length-prefixed-stream": "^1.5.0", + "interface-connection": "^0.2.1", "libp2p-crypto": "^0.5.0", "multihashing": "^0.2.1", - "node-forge": "^0.6.39", + "node-forge": "^0.6.42", "peer-id": "^0.7.0", "protocol-buffers": "^3.1.6", - "readable-stream": "2.1.4", - "run-series": "^1.1.4", - "through2": "^2.0.1" + "pull-defer": "^0.2.2", + "pull-handshake": "^1.1.4", + "pull-length-prefixed": "^1.2.0", + "pull-stream": "^3.4.3", + "pull-through": "^1.0.18", + "run-series": "^1.1.4" }, "devDependencies": { - "aegir": "^6.0.0", - "bl": "^1.1.2", + "aegir": "^8.0.0", "chai": "^3.5.0", - "multistream-select": "^0.10.0", + "multistream-select": "^0.11.0", "pre-commit": "^1.1.3", - "run-parallel": "^1.1.6", - "stream-pair": "^1.0.3" + "pull-pair": "^1.1.0", + "run-parallel": "^1.1.6" }, "pre-commit": [ "lint", @@ -65,4 +65,4 @@ "contributors": [ "dignifiedquire " ] -} \ No newline at end of file +} diff --git a/src/etm.js b/src/etm.js index 45ea158..a55d931 100644 --- a/src/etm.js +++ b/src/etm.js @@ -1,41 +1,44 @@ 'use strict' -const through = require('through2') -const lpm = require('length-prefixed-stream') +const through = require('pull-through') +const pull = require('pull-stream') +const lp = require('pull-length-prefixed') const toForgeBuffer = require('./support').toForgeBuffer -exports.writer = function etmWriter (insecure, cipher, mac) { - const encode = lpm.encode() - const pt = through(function (chunk, enc, cb) { +const lpOpts = { + fixed: true, + bytes: 4 +} + +exports.createBoxStream = (cipher, mac) => { + const pt = through(function (chunk) { cipher.update(toForgeBuffer(chunk)) if (cipher.output.length() > 0) { const data = new Buffer(cipher.output.getBytes(), 'binary') - mac.update(data) - const macBuffer = new Buffer(mac.getMac().getBytes(), 'binary') + mac.update(data.toString('binary')) + const macBuffer = new Buffer(mac.digest().getBytes(), 'binary') - this.push(Buffer.concat([data, macBuffer])) + this.queue(Buffer.concat([data, macBuffer])) // reset hmac mac.start(null, null) } - - cb() }) - pt.pipe(encode).pipe(insecure) - - return pt + return pull( + pt, + lp.encode(lpOpts) + ) } -exports.reader = function etmReader (insecure, decipher, mac) { - const decode = lpm.decode() - const pt = through(function (chunk, enc, cb) { +exports.createUnboxStream = (decipher, mac) => { + const pt = through(function (chunk) { const l = chunk.length const macSize = mac.getMac().length() if (l < macSize) { - return cb(new Error(`buffer (${l}) shorter than MAC size (${macSize})`)) + return this.emit('error', new Error(`buffer (${l}) shorter than MAC size (${macSize})`)) } const mark = l - macSize @@ -45,12 +48,13 @@ exports.reader = function etmReader (insecure, decipher, mac) { // Clear out any previous data mac.start(null, null) - mac.update(data) + mac.update(data.toString('binary')) const expected = new Buffer(mac.getMac().getBytes(), 'binary') + // reset hmac mac.start(null, null) if (!macd.equals(expected)) { - return cb(new Error(`MAC Invalid: ${macd.toString('hex')} != ${expected.toString('hex')}`)) + return this.emit('error', new Error(`MAC Invalid: ${macd.toString('hex')} != ${expected.toString('hex')}`)) } // all good, decrypt @@ -58,13 +62,12 @@ exports.reader = function etmReader (insecure, decipher, mac) { if (decipher.output.length() > 0) { const data = new Buffer(decipher.output.getBytes(), 'binary') - this.push(data) + this.queue(data) } - - cb() }) - insecure.pipe(decode).pipe(pt) - - return pt + return pull( + lp.decode(lpOpts), + pt + ) } diff --git a/src/handshake/crypto.js b/src/handshake/crypto.js new file mode 100644 index 0000000..6f9134a --- /dev/null +++ b/src/handshake/crypto.js @@ -0,0 +1,163 @@ +'use strict' + +const protobuf = require('protocol-buffers') +const path = require('path') +const fs = require('fs') +const PeerId = require('peer-id') +const crypto = require('libp2p-crypto') +const debug = require('debug') +const log = debug('libp2p:secio') +log.error = debug('libp2p:secio:error') + +const pbm = protobuf(fs.readFileSync(path.join(__dirname, 'secio.proto'))) + +const support = require('../support') + +// nonceSize is the size of our nonces (in bytes) +const nonceSize = 16 + +exports.createProposal = (state) => { + state.proposal.out = { + rand: support.randomBytes(nonceSize), + pubkey: state.key.local.public.bytes, + exchanges: support.exchanges.join(','), + ciphers: support.ciphers.join(','), + hashes: support.hashes.join(',') + } + + state.proposalEncoded.out = pbm.Propose.encode(state.proposal.out) + return state.proposalEncoded.out +} + +exports.createExchange = (state) => { + const res = crypto.generateEphemeralKeyPair(state.protocols.local.curveT) + state.ephemeralKey.local = res.key + state.shared.generate = res.genSharedKey + + // Gather corpus to sign. + const selectionOut = Buffer.concat([ + state.proposalEncoded.out, + state.proposalEncoded.in, + state.ephemeralKey.local + ]) + + state.exchange.out = { + epubkey: state.ephemeralKey.local, + signature: new Buffer(state.key.local.sign(selectionOut), 'binary') + } + + return pbm.Exchange.encode(state.exchange.out) +} + +exports.identify = (state, msg) => { + log('1.1 identify') + + state.proposalEncoded.in = msg + state.proposal.in = pbm.Propose.decode(msg) + const pubkey = state.proposal.in.pubkey + + console.log(state.proposal.in) + + state.key.remote = crypto.unmarshalPublicKey(pubkey) + state.id.remote = PeerId.createFromPubKey(pubkey.toString('base64')) + + log('1.1 identify - %s - identified remote peer as %s', state.id.local.toB58String(), state.id.remote.toB58String()) +} + +exports.selectProtocols = (state) => { + log('1.2 selection') + + const local = { + pubKeyBytes: state.key.local.public.bytes, + exchanges: support.exchanges, + hashes: support.hashes, + ciphers: support.ciphers, + nonce: state.proposal.out.rand + } + + const remote = { + pubKeyBytes: state.proposal.in.pubkey, + exchanges: state.proposal.in.exchanges.split(','), + hashes: state.proposal.in.hashes.split(','), + ciphers: state.proposal.in.ciphers.split(','), + nonce: state.proposal.in.rand + } + + let selected = support.selectBest(local, remote) + // we use the same params for both directions (must choose same curve) + // WARNING: if they dont SelectBest the same way, this won't work... + state.protocols.remote = { + order: selected.order, + curveT: selected.curveT, + cipherT: selected.cipherT, + hashT: selected.hashT + } + + state.protocols.local = { + order: selected.order, + curveT: selected.curveT, + cipherT: selected.cipherT, + hashT: selected.hashT + } +} + +exports.verify = (state, msg) => { + log('2.1. verify') + + state.exchange.in = pbm.Exchange.decode(msg) + state.ephemeralKey.remote = state.exchange.in.epubkey + + const selectionIn = Buffer.concat([ + state.proposalEncoded.in, + state.proposalEncoded.out, + state.ephemeralKey.remote + ]) + + const sigOk = state.key.remote.verify(selectionIn, state.exchange.in.signature) + + if (!sigOk) { + throw new Error('Bad signature') + } + + log('2.1. verify - signature verified') +} + +exports.generateKeys = (state) => { + log('2.2. keys') + + state.shared.secret = state.shared.generate(state.exchange.in.epubkey) + + const keys = crypto.keyStretcher( + state.protocols.local.cipherT, + state.protocols.local.hashT, + state.shared.secret + ) + + // use random nonces to decide order. + if (state.protocols.local.order > 0) { + state.protocols.local.keys = keys.k1 + state.protocols.remote.keys = keys.k2 + } else if (state.protocols.local.order < 0) { + // swap + state.protocols.local.keys = keys.k2 + state.protocols.remote.keys = keys.k1 + } else { + // we should've bailed before state. but if not, bail here. + throw new Error('you are trying to talk to yourself') + } + + log('2.3. mac + cipher') + + support.makeMacAndCipher(state.protocols.local) + support.makeMacAndCipher(state.protocols.remote) +} + +exports.verifyNonce = (state, n2) => { + const n1 = state.proposal.out.rand + + if (n1.equals(n2)) return + + throw new Error( + `Failed to read our encrypted nonce: ${n1.toString('hex')} != ${n2.toString('hex')}` + ) +} diff --git a/src/handshake/exchange.js b/src/handshake/exchange.js index 981c4d6..79d51fc 100644 --- a/src/handshake/exchange.js +++ b/src/handshake/exchange.js @@ -1,43 +1,30 @@ 'use strict' -const crypto = require('libp2p-crypto') const debug = require('debug') -const fs = require('fs') -const path = require('path') -const protobuf = require('protocol-buffers') + +const support = require('../support') +const crypto = require('./crypto') const log = debug('libp2p:secio') log.error = debug('libp2p:secio:error') -const pbm = protobuf(fs.readFileSync(path.join(__dirname, '../secio.proto'))) - -const support = require('../support') - // step 2. Exchange // -- exchange (signed) ephemeral keys. verify signatures. -module.exports = function exchange (session, cb) { +module.exports = function exchange (state, cb) { log('2. exchange - start') - let genSharedKey - let exchangeOut - - try { - const eResult = crypto.generateEphemeralKeyPair(session.local.curveT) - session.local.ephemeralPubKey = eResult.key - genSharedKey = eResult.genSharedKey - exchangeOut = makeExchange(session) - } catch (err) { - return cb(err) - } + log('2. exchange - writing exchange') + support.write(state, crypto.createExchange(state)) + support.read(state.shake, (err, msg) => { + if (err) { + return cb(err) + } - session.insecureLp.write(exchangeOut) - session.insecureLp.once('data', (chunk) => { - const exchangeIn = pbm.Exchange.decode(chunk) + log('2. exchange - reading exchange') try { - verify(session, exchangeIn) - keys(session, exchangeIn, genSharedKey) - macAndCipher(session) + crypto.verify(state, msg) + crypto.generateKeys(state) } catch (err) { return cb(err) } @@ -46,63 +33,3 @@ module.exports = function exchange (session, cb) { cb() }) } - -function makeExchange (session) { - // Gather corpus to sign. - const selectionOut = Buffer.concat([ - session.proposal.out, - session.proposal.in, - session.local.ephemeralPubKey - ]) - - const epubkey = session.local.ephemeralPubKey - const signature = new Buffer(session.localKey.sign(selectionOut), 'binary') - log('out', {epubkey, signature}) - return pbm.Exchange.encode({epubkey, signature}) -} - -function verify (session, exchangeIn) { - log('2.1. verify', exchangeIn) - - session.remote.ephemeralPubKey = exchangeIn.epubkey - const selectionIn = Buffer.concat([ - session.proposal.in, - session.proposal.out, - session.remote.ephemeralPubKey - ]) - const sigOk = session.remote.permanentPubKey.verify(selectionIn, exchangeIn.signature) - - if (!sigOk) { - throw new Error('Bad signature') - } - - log('2.1. verify - signature verified') -} - -function keys (session, exchangeIn, genSharedKey) { - log('2.2. keys') - - session.sharedSecret = genSharedKey(exchangeIn.epubkey) - - const keys = crypto.keyStretcher(session.local.cipherT, session.local.hashT, session.sharedSecret) - - // use random nonces to decide order. - if (session.proposal.order > 0) { - session.local.keys = keys.k1 - session.remote.keys = keys.k2 - } else if (session.proposal.order < 0) { - // swap - session.local.keys = keys.k2 - session.remote.keys = keys.k1 - } else { - // we should've bailed before this. but if not, bail here. - throw new Error('you are trying to talk to yourself') - } -} - -function macAndCipher (session) { - log('2.3. mac + cipher') - - support.makeMacAndCipher(session.local) - support.makeMacAndCipher(session.remote) -} diff --git a/src/handshake/finish.js b/src/handshake/finish.js index 1e98248..92874b1 100644 --- a/src/handshake/finish.js +++ b/src/handshake/finish.js @@ -1,35 +1,56 @@ 'use strict' -const duplexify = require('duplexify') +const pull = require('pull-stream') +const handshake = require('pull-handshake') const debug = require('debug') + const log = debug('libp2p:secio') log.error = debug('libp2p:secio:error') -const read = require('async-buffered-reader') const etm = require('../etm') +const crypto = require('./crypto') // step 3. Finish // -- send expected message to verify encryption works (send local nonce) -module.exports = function finish (session, cb) { +module.exports = function finish (state, cb) { log('3. finish - start') - const w = etm.writer(session.insecure, session.local.cipher, session.local.mac) - const r = etm.reader(session.insecure, session.remote.cipher, session.remote.mac) - session.secure = duplexify(w, r) - session.secure.write(session.proposal.randIn) + const proto = state.protocols + const stream = state.shake.rest() + const shake = handshake({timeout: state.timeout}) + + pull( + stream, + etm.createUnboxStream(proto.remote.cipher, proto.remote.mac), + shake, + etm.createBoxStream(proto.local.cipher, proto.local.mac), + stream + ) - // read our nonce back - read(session.secure, 16, (nonceOut2) => { - const nonceOut = session.proposal.nonceOut - if (!nonceOut.equals(nonceOut2)) { - const err = new Error(`Failed to read our encrypted nonce: ${nonceOut.toString('hex')} != ${nonceOut2.toString('hex')}`) + shake.handshake.write(state.proposal.in.rand) + shake.handshake.read(state.proposal.in.rand.length, (err, nonceBack) => { + const fail = (err) => { log.error(err) - return cb(err) + state.secure.resolve({ + source: pull.error(err), + sink (read) { + } + }) + cb(err) + } + + if (err) return fail(err) + + try { + crypto.verifyNonce(state, nonceBack) + } catch (err) { + return fail(err) } - log('3. finish - finish', nonceOut.toString('hex'), nonceOut2.toString('hex')) + log('3. finish - finish') // Awesome that's all folks. + state.secure.resolve(shake.handshake.rest()) cb() }) } diff --git a/src/handshake/index.js b/src/handshake/index.js index 993fcc5..75894b5 100644 --- a/src/handshake/index.js +++ b/src/handshake/index.js @@ -1,21 +1,25 @@ 'use strict' -const debug = require('debug') const series = require('run-series') -const log = debug('libp2p:secio') -log.error = debug('libp2p:secio:error') - const propose = require('./propose') const exchange = require('./exchange') const finish = require('./finish') // Performs initial communication over insecure channel to share // keys, IDs, and initiate communication, assigning all necessary params. -module.exports = function handshake (session, cb) { +module.exports = function handshake (state) { series([ - (cb) => propose(session, cb), - (cb) => exchange(session, cb), - (cb) => finish(session, cb) - ], cb) + (cb) => propose(state, cb), + (cb) => exchange(state, cb), + (cb) => finish(state, cb) + ], (err) => { + state.cleanSecrets() + + if (err) { + state.shake.abort(err) + } + }) + + return state.stream } diff --git a/src/handshake/propose.js b/src/handshake/propose.js index c4cb324..7023d8f 100644 --- a/src/handshake/propose.js +++ b/src/handshake/propose.js @@ -1,52 +1,30 @@ 'use strict' -const forge = require('node-forge') const debug = require('debug') -const protobuf = require('protocol-buffers') -const path = require('path') -const fs = require('fs') -const PeerId = require('peer-id') -const mh = require('multihashing') -const crypto = require('libp2p-crypto') + +const support = require('../support') +const crypto = require('./crypto') const log = debug('libp2p:secio') log.error = debug('libp2p:secio:error') -// nonceSize is the size of our nonces (in bytes) -const nonceSize = 16 - -const pbm = protobuf(fs.readFileSync(path.join(__dirname, '../secio.proto'))) -const support = require('../support') - // step 1. Propose // -- propose cipher suite + send pubkeys + nonce -module.exports = function propose (session, cb) { +module.exports = function propose (state, cb) { log('1. propose - start') - const nonceOut = new Buffer(forge.random.getBytesSync(nonceSize), 'binary') - const proposeOut = makeProposal(session, nonceOut) - - session.proposal.out = proposeOut - session.proposal.nonceOut = nonceOut - - log('1. propse - writing proposal') - session.insecureLp.write(proposeOut) - session.insecureLp.once('data', (chunk) => { - log('1. propse - reading proposal') - - let proposeIn - - try { - proposeIn = readProposal(chunk) - session.proposal.in = chunk - session.proposal.randIn = proposeIn.rand - identify(session, proposeIn) - } catch (err) { + log('1. propose - writing proposal') + support.write(state, crypto.createProposal(state)) + support.read(state.shake, (err, msg) => { + if (err) { return cb(err) } + log('1. propose - reading proposal', msg) + try { - selection(session, nonceOut, proposeIn) + crypto.identify(state, msg) + crypto.selectProtocols(state) } catch (err) { return cb(err) } @@ -56,91 +34,3 @@ module.exports = function propose (session, cb) { cb() }) } - -// Generate and send Hello packet. -// Hello = (rand, PublicKey, Supported) -function makeProposal (session, nonceOut) { - session.local.permanentPubKey = session.localKey.public - const myPubKeyBytes = session.local.permanentPubKey.bytes - - return pbm.Propose.encode({ - rand: nonceOut, - pubkey: myPubKeyBytes, - exchanges: support.exchanges.join(','), - ciphers: support.ciphers.join(','), - hashes: support.hashes.join(',') - }) -} - -function readProposal (bytes) { - return pbm.Propose.decode(bytes) -} - -function identify (session, proposeIn) { - log('1.1 identify') - - session.remote.permanentPubKey = crypto.unmarshalPublicKey(proposeIn.pubkey) - session.remotePeer = PeerId.createFromPubKey(proposeIn.pubkey.toString('base64')) - - log('1.1 identify - %s - identified remote peer as %s', session.localPeer.toB58String(), session.remotePeer.toB58String()) -} - -function selection (session, nonceOut, proposeIn) { - log('1.2 selection') - - const local = { - pubKeyBytes: session.local.permanentPubKey.bytes, - exchanges: support.exchanges, - hashes: support.hashes, - ciphers: support.ciphers, - nonce: nonceOut - } - - const remote = { - pubKeyBytes: proposeIn.pubkey, - exchanges: proposeIn.exchanges.split(','), - hashes: proposeIn.hashes.split(','), - ciphers: proposeIn.ciphers.split(','), - nonce: proposeIn.rand - } - - let selected = selectBest(local, remote) - session.proposal.order = selected.order - - session.local.curveT = selected.curveT - session.local.cipherT = selected.cipherT - session.local.hashT = selected.hashT - - // we use the same params for both directions (must choose same curve) - // WARNING: if they dont SelectBest the same way, this won't work... - session.remote.curveT = session.local.curveT - session.remote.cipherT = session.local.cipherT - session.remote.hashT = session.local.hashT -} - -function selectBest (local, remote) { - const oh1 = digest(Buffer.concat([ - remote.pubKeyBytes, - local.nonce - ])) - const oh2 = digest(Buffer.concat([ - local.pubKeyBytes, - remote.nonce - ])) - const order = Buffer.compare(oh1, oh2) - - if (order === 0) { - throw new Error('you are trying to talk to yourself') - } - - return { - curveT: support.theBest(order, local.exchanges, remote.exchanges), - cipherT: support.theBest(order, local.ciphers, remote.ciphers), - hashT: support.theBest(order, local.hashes, remote.hashes), - order - } -} - -function digest (buf) { - return mh.digest(buf, 'sha2-256', buf.length) -} diff --git a/src/secio.proto b/src/handshake/secio.proto similarity index 100% rename from src/secio.proto rename to src/handshake/secio.proto diff --git a/src/index.js b/src/index.js index dd925a4..73597d3 100644 --- a/src/index.js +++ b/src/index.js @@ -1,119 +1,36 @@ 'use strict' -const duplexify = require('duplexify') -const lpstream = require('length-prefixed-stream') -const PassThrough = require('readable-stream').PassThrough +const pull = require('pull-stream') +const Connection = require('interface-connection').Connection const handshake = require('./handshake') +const State = require('./state') exports.SecureSession = class SecureSession { constructor (local, key, insecure) { - this.localKey = key - this.localPeer = local - this.sharedSecret = null - this.local = {} - this.remote = {} - this.proposal = {} - this.insecure = insecure - this.secure = null - const e = lpstream.encode() - const d = lpstream.decode() - this.insecureLp = duplexify(e, d) - - e.pipe(this.insecure) - this.insecure.pipe(d) - - if (!this.localPeer) { + if (!local) { throw new Error('no local id provided') } - if (!this.localKey) { + if (!key) { throw new Error('no local private key provided') } - // Enable when implemented in js-peer-id - // if (!this.localPeer.matchesPrivateKey(this.localKey)) { - // throw new Error('peer.ID does not match privateKey') - // } - if (!insecure) { throw new Error('no insecure stream provided') } - } - - secureStream () { - let handshaked = false - const reader = new PassThrough() - const writer = new PassThrough() - const dp = duplexify(writer, reader) - const originalRead = reader.read.bind(reader) - const originalWrite = writer.write.bind(writer) - - const doHandshake = () => { - if (handshaked) return - - handshaked = true - - // Restore methods to avoid overhead - reader.read = originalRead - writer.write = originalWrite - - this.handshake((err) => { - if (err) { - dp.emit('error', err) - } - - // Pipe things together - writer.pipe(this.secure) - this.secure.pipe(reader) - - dp.uncork() - dp.resume() - }) - } - // patch to detect first read - reader.read = (size) => { - doHandshake() - originalRead(size) - } - - // patch to detect first write - writer.write = (chunk, encoding, callback) => { - doHandshake() - originalWrite(chunk, encoding, callback) - } - - dp.cork() - dp.pause() + this.state = new State(local, key) + this.insecure = insecure - return dp + pull( + this.insecure, + handshake(this.state), + this.insecure + ) } - handshake (cb) { - // TODO: figure out how to best handle the handshake timeout - if (this._handshakeLock) { - return cb(new Error('handshake already in progress')) - } - - this._handshakeLock = true - - const finish = (err) => { - this._handshakeLock = false - cb(err) - } - - if (this._handshakeDone) { - return finish() - } - - handshake(this, (err) => { - if (err) { - return finish(err) - } - - this._handshakeDone = true - finish() - }) + get secure () { + return new Connection(this.state.secure, this.insecure) } } diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..abedf02 --- /dev/null +++ b/src/state.js @@ -0,0 +1,67 @@ +'use strict' + +const handshake = require('pull-handshake') +const deferred = require('pull-defer') + +class State { + constructor (id, key, timeout, cb) { + this.setup() + this.id.local = id + this.key.local = key + this.timeout = timeout || 60 * 1000 + cb = cb || (() => {}) + + this.secure = deferred.duplex() + this.stream = handshake({timeout: this.timeout}, cb) + this.shake = this.stream.handshake + delete this.stream.handshake + } + + setup () { + this.id = { + local: null, + remote: null + } + + this.key = { + local: null, + remote: null + } + + this.shake = null + + this.cleanSecrets() + } + + // remove all data from the handshake that is not needed anymore + cleanSecrets () { + this.shared = {} + + this.ephemeralKey = { + local: null, + remote: null + } + + this.proposal = { + in: null, + out: null + } + + this.proposalEncoded = { + in: null, + out: null + } + + this.protocols = { + local: null, + remote: null + } + + this.exchange = { + in: null, + out: null + } + } +} + +module.exports = State diff --git a/src/support.js b/src/support.js index b23b157..efbbd06 100644 --- a/src/support.js +++ b/src/support.js @@ -1,6 +1,9 @@ 'use strict' +const mh = require('multihashing') const forge = require('node-forge') +const lp = require('pull-length-prefixed') +const pull = require('pull-stream') exports.exchanges = [ 'P-256', @@ -86,3 +89,55 @@ function makeCipher (cipherType, iv, key) { throw new Error(`unrecognized cipher type: ${cipherType}`) } + +exports.randomBytes = (nonceSize) => { + return new Buffer(forge.random.getBytesSync(nonceSize), 'binary') +} + +exports.selectBest = (local, remote) => { + const oh1 = exports.digest(Buffer.concat([ + remote.pubKeyBytes, + local.nonce + ])) + const oh2 = exports.digest(Buffer.concat([ + local.pubKeyBytes, + remote.nonce + ])) + const order = Buffer.compare(oh1, oh2) + + if (order === 0) { + throw new Error('you are trying to talk to yourself') + } + + return { + curveT: exports.theBest(order, local.exchanges, remote.exchanges), + cipherT: exports.theBest(order, local.ciphers, remote.ciphers), + hashT: exports.theBest(order, local.hashes, remote.hashes), + order + } +} + +exports.digest = (buf) => { + return mh.digest(buf, 'sha2-256', buf.length) +} + +exports.write = function write (state, msg, cb) { + cb = cb || (() => {}) + pull( + pull.values([ + msg + ]), + lp.encode({fixed: true, bytes: 4}), + pull.collect((err, res) => { + if (err) { + return cb(err) + } + state.shake.write(res[0]) + cb() + }) + ) +} + +exports.read = function read (reader, cb) { + lp.decodeFromReader(reader, {fixed: true, bytes: 4}, cb) +} diff --git a/test/index.spec.js b/test/index.spec.js index acf2c5b..e1ec3c0 100644 --- a/test/index.spec.js +++ b/test/index.spec.js @@ -1,92 +1,45 @@ /* eslint-env mocha */ 'use strict' +const pair = require('pull-pair/duplex') const expect = require('chai').expect -const through = require('through2') -const bl = require('bl') const PeerId = require('peer-id') const crypto = require('libp2p-crypto') -const streamPair = require('stream-pair') const parallel = require('run-parallel') const series = require('run-series') const ms = require('multistream-select') +const pull = require('pull-stream') const Listener = ms.Listener const Dialer = ms.Dialer const SecureSession = require('../src').SecureSession describe('libp2p-secio', () => { - describe('insecure length prefixed stream', () => { - it('encodes', (done) => { - const id = PeerId.create({bits: 64}) - const key = {} - const insecure = through() - const s = new SecureSession(id, key, insecure) - - // encoded on raw - s.insecure.pipe(bl((err, res) => { - expect(err).to.not.exist - expect(res.toString()).to.be.eql('\u0005hello\u0005world') - done() - })) + it('upgrades a connection', (done) => { + const p = pair() - s.insecureLp.write('hello') - s.insecureLp.write('world') - insecure.end() - }) + const local = createSession(p[0]) + const remote = createSession(p[1]) + const localSecure = local.session.secure - it('decodes', (done) => { - const id = PeerId.create({bits: 64}) - const key = {} - const insecure = through() - const s = new SecureSession(id, key, insecure) + pull( + pull.values(['hello world']), + localSecure + ) - // encoded on raw - s.insecureLp.pipe(bl((err, res) => { + const remoteSecure = remote.session.secure + pull( + remoteSecure, + pull.collect((err, chunks) => { expect(err).to.not.exist - expect(res.toString()).to.be.eql('helloworld') - done() - })) - - s.insecure.write('\u0005hello') - s.insecure.write('\u0005world') - s.insecureLp.end() - }) - - it('all together now', (done) => { - const pair = streamPair.create() - - const local = createSession(pair) - const remote = createSession(pair.other) - remote.session.insecureLp.pipe(bl((err, res) => { - if (err) throw err - expect(res.toString()).to.be.eql('hello world') + expect(chunks).to.be.eql([new Buffer('hello world')]) done() - })) - - local.session.insecureLp.write('hello ') - local.session.insecureLp.write('world') - pair.end() - }) - }) - - it('upgrades a connection', (done) => { - const pair = streamPair.create() - - const local = createSession(pair) - const remote = createSession(pair.other) - const localSecure = local.session.secureStream() - localSecure.write('hello world') - - const remoteSecure = remote.session.secureStream() - remoteSecure.once('data', (chunk) => { - expect(chunk.toString()).to.be.eql('hello world') - done() - }) + }) + ) }) it('works over multistream', (done) => { - const pair = streamPair.create() + const p = pair() const listener = new Listener() const dialer = new Dialer() @@ -94,22 +47,29 @@ describe('libp2p-secio', () => { let remote series([ (cb) => parallel([ - (cb) => listener.handle(pair, cb), - (cb) => dialer.handle(pair.other, cb) + (cb) => listener.handle(p[0], cb), + (cb) => dialer.handle(p[1], cb) ], cb), (cb) => { listener.addHandler('/banana/1.0.0', (conn) => { - local = createSession(conn).session.secureStream() - local.once('data', (res) => { - expect(res.toString()).to.be.eql('hello world') - done() - }) + local = createSession(conn).session.secure + pull( + local, + pull.collect((err, chunks) => { + expect(err).to.not.exist + expect(chunks).to.be.eql([new Buffer('hello world')]) + done() + }) + ) }) cb() }, (cb) => dialer.select('/banana/1.0.0', (err, conn) => { - remote = createSession(conn).session.secureStream() - remote.write('hello world') + remote = createSession(conn).session.secure + pull( + pull.values(['hello world']), + remote + ) cb(err) }) ], (err) => {