├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json └── test └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | before_script: 3 | - npm install 4 | script: 5 | - npm test 6 | node_js: 7 | - '0.10' 8 | - '0.12' 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dominic Tarr 4 | 5 | Permission is hereby granted, free of charge, 6 | to any person obtaining a copy of this software and 7 | associated documentation files (the "Software"), to 8 | deal in the Software without restriction, including 9 | without limitation the rights to use, copy, modify, 10 | merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom 12 | the Software is furnished to do so, 13 | subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice 16 | shall be included in all copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 20 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 21 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR 22 | ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 23 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 24 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pull-box-stream 2 | 3 | stream _one way_ encryption based on [libsodium](https://github.com/paixaop/node-sodium)'s secretbox primitive. 4 | 5 | ![Travis CI Status](https://travis-ci.org/dominictarr/pull-box-stream) 6 | 7 | This protocol should not be used to encrypt a tcp connection 8 | unless it was combined with a handshake protocol 9 | that was used to derive a forward secure shared key. 10 | 11 | It may be used to encrypt a file. 12 | 13 | ## Claims 14 | 15 | ### All bytes are authenticated & encrypted. 16 | 17 | 18 | * The reciever never reads an unauthenticated number of bytes. 19 | 20 | This protects against attackers causing deadlocks on certain application protocols protected with box-stream. 21 | (description of this attack on 22 | [old version of hmac-stream](https://github.com/calvinmetcalf/hmac-stream/issues/5)) 23 | 24 | * The end of the stream is authenticated. 25 | 26 | This detects if an attacker cut off the end of the stream. 27 | for example: 28 | 29 | Alice: hey bob, just calling to say that I think TLS is really great, 30 | really elegant protocol, and that I love everything about it. 31 | 32 | Mallory (man in the middle): (SNIP! ...terminates connection...) 33 | 34 | Alice: NOT!!!!! (Bob never receives this!) 35 | 36 | Bob... WTF, I thought Alice had taste! 37 | 38 | Bob never gets the punchline, so thinks that Alice's childish humor was 39 | actually her sincere belief. 40 | 41 | With box-stream this would result in an error and Bob would know 42 | that there was some additional content he missed which hopefully 43 | explained Alice's absurd statement. 44 | 45 | ## Disclaims 46 | 47 | * This protocol does not obscure packet boundries or packet timing. 48 | * This protocol is not a substitute for TLS, it must be used with another handshake protocol to derive a shared key. 49 | 50 | ## Protocol 51 | 52 | This protocol has no malleable bytes. 53 | Even the framing is authenticated, and since the framing is 54 | authenticated separately to the packet content, an attacker cannot 55 | flip any bits without being immediately detected. 56 | 57 | The design follows on from that used in 58 | [pull-mac](https://github.com/dominictarr/pull-mac), 59 | where both the framing and the framed packet are authenticated. 60 | 61 | In `pull-mac`, the packet is hashed, and then the header hmac'd. 62 | Since the header contains the packet hash and the packet length, 63 | then changing a bit in the packet will produce a different hash 64 | and thus an invalid packet. Flipping a bit in the header will 65 | invalidate the hmac. 66 | 67 | In `pull-boxes` a similar approach is used, but via nacl's authenticated 68 | encryption primitive: `secretbox`. salsa20 encryption + poly1305 mac. 69 | The packet is boxed, then the header is constructed from the packet 70 | length + packet mac, then the header is boxed. 71 | 72 | This protocol uses a 56 byte key (448 bits). The first 32 bytes 73 | are the salsa20 key, and the last 24 bytes are the nonce. Previous 74 | verisons of this protocol generated a nonce and transmitted it, 75 | but it could be simplified by considering it part of the key. 76 | 77 | Since every header and packet body are encrypted, 78 | then every byte in the stream appears random. 79 | 80 | The only information an evesdropper can extract is 81 | packet timing and to guess at packet boundries 82 | (although, sometimes packets will be appended, obscuring the true boundries) 83 | 84 | ## Example 85 | 86 | ``` js 87 | var boxes = require('pull-box-stream') 88 | //generate a random secret, 56 bytes long. 89 | 90 | var key = createRandomSecret(56) 91 | 92 | pull( 93 | plaintext_input, 94 | 95 | //encrypt every byte 96 | boxes.createBoxStream(key), 97 | 98 | //the encrypted stream 99 | pull.through(console.log), 100 | 101 | //decrypt every byte 102 | boxes.createUnboxStream(key), 103 | 104 | plaintext_output 105 | ) 106 | 107 | 108 | ``` 109 | 110 | ## Protocol 111 | 112 | ``` 113 | ( 114 | 115 | [header MAC (16)] // sends header MAC 116 | | 117 | | .--header-box-----------------. 118 | \-> |length (2), [packet MAC (16)]| // sends encrypted header 119 | `--^------------|-------------` 120 | | | 121 | | | .-packet-box-------. 122 | | `->|data.. (length...)| // sends encrypted packet 123 | | `-----------|------` 124 | \---------------------------/ 125 | 126 | ) * // repeat 0-N times 127 | 128 | [final header MAC(16)] 129 | | 130 | | .-final-header-box-------. 131 | \->|length=0 (2), zeros (16)| 132 | `------------------------` 133 | ``` 134 | 135 | Since the packet mac is inside the header box, the packet 136 | must be boxed first. 137 | 138 | The last 24 bytes of the 56 byte key is used as the nonce. 139 | When boxing, you must use a different nonce everytime a particular key is used. 140 | 141 | The recommended way to do this is to randomly generate an initial 142 | nonce for that key, and then increment that nonce on each boxing. 143 | (this way security is not dependant on the random number generator) 144 | 145 | The protocol sends zero or more {header, packet} pairs, then a final 146 | header, that is same length, but is just boxed zeros. 147 | Each header is 34 bytes long (header mac + packet_length + packet mac). 148 | Then the packet_length is length long (with a maximum length of 4096 149 | bytes long, if the in coming packet is longer than that it is split 150 | into 4096 byte long sections.) 151 | 152 | Packet number P uses N+2P as the nonce on the header box, 153 | and N+2P+1 as the nonce on the packet box. 154 | 155 | A final packet is sent so that an incorrectly terminated session 156 | can be detected. 157 | 158 | ## License 159 | 160 | MIT 161 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | var sodium = require('chloride') 3 | var Reader = require('pull-reader') 4 | var increment = require('increment-buffer') 5 | var through = require('pull-through') 6 | var split = require('split-buffer') 7 | 8 | var isBuffer = Buffer.isBuffer 9 | var concat = Buffer.concat 10 | 11 | var box = sodium.crypto_secretbox_easy 12 | var unbox = sodium.crypto_secretbox_open_easy 13 | 14 | function unbox_detached (mac, boxed, nonce, key) { 15 | return sodium.crypto_secretbox_open_easy(concat([mac, boxed]), nonce, key) 16 | } 17 | 18 | var max = 1024*4 19 | 20 | var NONCE_LEN = 24 21 | var HEADER_LEN = 2+16+16 22 | 23 | function isZeros(b) { 24 | for(var i = 0; i < b.length; i++) 25 | if(b[i] !== 0) return false 26 | return true 27 | } 28 | 29 | function randomSecret(n) { 30 | var rand = new Buffer(n) 31 | sodium.randombytes(rand) 32 | return rand 33 | } 34 | 35 | function copy (a) { 36 | var b = new Buffer(a.length) 37 | a.copy(b, 0, 0, a.length) 38 | return b 39 | } 40 | 41 | exports.createBoxStream = 42 | exports.createEncryptStream = function (key, init_nonce) { 43 | 44 | if(key.length === 56) { 45 | init_nonce = key.slice(32, 56) 46 | key = key.slice(0, 32) 47 | } 48 | else if(!(key.length === 32 && init_nonce.length === 24)) 49 | throw new Error('nonce must be 24 bytes') 50 | 51 | // we need two nonces because increment mutates, 52 | // and we need the next for the header, 53 | // and the next next nonce for the packet 54 | var nonce1 = copy(init_nonce), nonce2 = copy(init_nonce) 55 | var head = new Buffer(18) 56 | 57 | return through(function (data) { 58 | 59 | if('string' === typeof data) 60 | data = new Buffer(data, 'utf8') 61 | else if(!isBuffer(data)) 62 | return this.emit('error', new Error('must be buffer')) 63 | 64 | if(data.length === 0) return 65 | 66 | var input = split(data, max) 67 | 68 | for(var i = 0; i < input.length; i++) { 69 | head.writeUInt16BE(input[i].length, 0) 70 | var boxed = box(input[i], increment(nonce2), key) 71 | //write the mac into the header. 72 | boxed.copy(head, 2, 0, 16) 73 | 74 | this.queue(box(head, nonce1, key)) 75 | this.queue(boxed.slice(16, 16 + input[i].length)) 76 | 77 | increment(increment(nonce1)); increment(nonce2) 78 | } 79 | }, function (err) { 80 | if(err) return this.queue(null) 81 | 82 | //handle special-case of empty session 83 | //final header is same length as header except all zeros (inside box) 84 | var final = new Buffer(2+16); final.fill(0) 85 | this.queue(box(final, nonce1, key)) 86 | this.queue(null) 87 | }) 88 | 89 | } 90 | exports.createUnboxStream = 91 | exports.createDecryptStream = function (key, nonce) { 92 | 93 | 94 | if(key.length == 56) { 95 | nonce = key.slice(32, 56) 96 | key = key.slice(0, 32) 97 | } 98 | else if(!(key.length === 32 && nonce.length === 24)) 99 | throw new Error('nonce must be 24 bytes') 100 | nonce = copy(nonce) 101 | 102 | var reader = Reader(), first = true, ended 103 | var first = true 104 | 105 | return function (read) { 106 | reader(read) 107 | return function (end, cb) { 108 | if(end) return reader.abort(end, cb) 109 | //use abort when the input was invalid, 110 | //but the source hasn't actually ended yet. 111 | function abort(err) { 112 | reader.abort(ended = err || true, cb) 113 | } 114 | 115 | if(ended) return cb(ended) 116 | reader.read(HEADER_LEN, function (err, cipherheader) { 117 | if(err === true) return cb(ended = new Error('unexpected hangup')) 118 | if(err) return cb(ended = err) 119 | 120 | var header = unbox(cipherheader, nonce, key) 121 | 122 | if(!header) 123 | return abort(new Error('invalid header')) 124 | 125 | //valid end of stream 126 | if(isZeros(header)) 127 | return cb(ended = true) 128 | 129 | var length = header.readUInt16BE(0) 130 | var mac = header.slice(2, 34) 131 | 132 | reader.read(length, function (err, cipherpacket) { 133 | if(err) return cb(ended = err) 134 | //recreate a valid packet 135 | //TODO: PR to sodium bindings for detached box/open 136 | var plainpacket = unbox_detached(mac, cipherpacket, increment(nonce), key) 137 | if(!plainpacket) 138 | return abort(new Error('invalid packet')) 139 | 140 | increment(nonce) 141 | cb(null, plainpacket) 142 | }) 143 | }) 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pull-box-stream", 3 | "description": "every-byte-is-encrypted and authenticated pull-stream", 4 | "version": "1.0.13", 5 | "homepage": "https://github.com/dominictarr/pull-box-stream", 6 | "repository": { 7 | "type": "git", 8 | "url": "git://github.com/dominictarr/pull-box-stream.git" 9 | }, 10 | "dependencies": { 11 | "chloride": "^2.2.7", 12 | "increment-buffer": "~1.0.0", 13 | "pull-reader": "^1.2.5", 14 | "pull-stream": "^3.2.3", 15 | "pull-through": "^1.0.18", 16 | "split-buffer": "~1.0.0" 17 | }, 18 | "devDependencies": { 19 | "tape": "~4.0.0", 20 | "pull-randomly-split": "~1.0.4", 21 | "pull-bitflipper": "~0.0.1" 22 | }, 23 | "scripts": { 24 | "prepublish": "npm ls && npm test", 25 | "test": "set -e; for t in test/*.js; do node $t; done" 26 | }, 27 | "author": "Dominic Tarr (http://dominictarr.com)", 28 | "license": "MIT" 29 | } 30 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | 2 | var tape = require('tape') 3 | var pull = require('pull-stream') 4 | var randomBytes = require('crypto').randomBytes 5 | var increment = require('increment-buffer') 6 | var split = require('pull-randomly-split') 7 | var boxes = require('../') 8 | var bitflipper = require('pull-bitflipper') 9 | 10 | var sodium = require('chloride') 11 | 12 | var box = sodium.crypto_secretbox_easy 13 | var unbox = sodium.crypto_secretbox_open_easy 14 | 15 | var concat = Buffer.concat 16 | 17 | //var zeros = new Buffer(16); zeros.fill(0) 18 | 19 | // testing is easier when 20 | 21 | function testKey (str) { 22 | return sodium.crypto_hash(new Buffer(str)).slice(0, 56) 23 | } 24 | 25 | tape('encrypt a stream', function (t) { 26 | 27 | var key = testKey('encrypt a stream - test 1') 28 | 29 | pull( 30 | pull.values([new Buffer('hello there')]), 31 | boxes.createEncryptStream(key), 32 | pull.collect(function (err, ary) { 33 | if(err) throw err 34 | //cipher text 35 | 36 | //decrypt the head. 37 | var head = ary[0] 38 | var chunk = ary[1] 39 | 40 | var _key = key.slice(0, 32) 41 | var _nonce = key.slice(32, 56) 42 | 43 | console.log(ary) 44 | console.log(ary.map(function (e) { return e.length })) 45 | 46 | var plainhead = unbox(head, _nonce, _key) 47 | var length = plainhead.readUInt16BE(0) 48 | 49 | t.equal(length, 11) 50 | t.equal(length, chunk.length) 51 | 52 | var mac = plainhead.slice(2, 18) 53 | var nonce2 = new Buffer(24) 54 | _nonce.copy(nonce2, 0, 0, 24) 55 | 56 | var plainchunk = 57 | unbox(concat([mac, chunk]), increment(nonce2), _key) 58 | 59 | t.deepEqual(plainchunk, new Buffer('hello there')) 60 | 61 | //now decrypt the same 62 | pull( 63 | pull.values(ary), 64 | boxes.createDecryptStream(key), 65 | pull.collect(function (err, data) { 66 | if(err) throw err 67 | t.deepEqual(data, [new Buffer('hello there')]) 68 | t.end() 69 | }) 70 | ) 71 | }) 72 | ) 73 | }) 74 | 75 | function randomBuffers(len, n) { 76 | var a = [] 77 | while(n--) 78 | a.push(randomBytes(len)) 79 | return a 80 | } 81 | 82 | tape('encrypt/decrypt simple', function (t) { 83 | 84 | var start = Date.now() 85 | console.log('e/d') 86 | var key = testKey('encrypt/decrypt a stream, easy') 87 | 88 | var input = [new Buffer('can you read this?'), new Buffer(4100)] 89 | pull( 90 | pull.values(input), 91 | boxes.createEncryptStream(new Buffer(key)), 92 | boxes.createDecryptStream(new Buffer(key)), 93 | pull.through(console.log), 94 | pull.collect(function (err, output) { 95 | var time = Date.now() - start 96 | console.log(100/(time/1000), 'mb/s') 97 | 98 | if(err) throw err 99 | 100 | output = concat(output) 101 | input = concat(input) 102 | t.equal(output.length, input.length) 103 | t.deepEqual(output, input) 104 | t.end() 105 | }) 106 | ) 107 | }) 108 | return 109 | 110 | tape('encrypt/decrypt', function (t) { 111 | 112 | var input = randomBuffers(1024*512, 2*10) 113 | var start = Date.now() 114 | console.log('e/d') 115 | var key = testKey('encrypt/decrypt a stream') 116 | 117 | pull( 118 | pull.values(input), 119 | split(), 120 | boxes.createEncryptStream(new Buffer(key)), 121 | split(), 122 | boxes.createDecryptStream(new Buffer(key)), 123 | pull.through(console.log), 124 | pull.collect(function (err, output) { 125 | var time = Date.now() - start 126 | console.log(100/(time/1000), 'mb/s') 127 | 128 | if(err) throw err 129 | 130 | output = concat(output) 131 | input = concat(input) 132 | t.equal(output.length, input.length) 133 | t.deepEqual(output, input) 134 | t.end() 135 | }) 136 | ) 137 | }) 138 | 139 | tape('error if input is not a buffer', function (t) { 140 | 141 | var key = testKey('error if not a buffer') 142 | 143 | pull( 144 | pull.values([0, 1, 2], function (err) { t.end() }), 145 | boxes.createEncryptStream(key), 146 | pull.collect(function (err) { 147 | console.log('error', err) 148 | t.ok(err) 149 | }) 150 | ) 151 | 152 | }) 153 | 154 | tape('detect flipped bits', function (t) { 155 | 156 | var input = randomBuffers(1024, 100) 157 | var key = testKey('bit flipper') 158 | 159 | pull( 160 | pull.values(input, function () { t.end() }), 161 | boxes.createEncryptStream(key), 162 | bitflipper(0.1), 163 | boxes.createDecryptStream(key), 164 | pull.collect(function (err, output) { 165 | t.ok(err) 166 | t.notEqual(output.length, input.length) 167 | }) 168 | ) 169 | 170 | }) 171 | 172 | function rand (i) { 173 | return ~~(Math.random()*i) 174 | } 175 | 176 | tape('protect against reordering', function (t) { 177 | 178 | var input = randomBuffers(1024, 100) 179 | var key = testKey('reordering') 180 | 181 | pull( 182 | pull.values(input), 183 | boxes.createEncryptStream(key), 184 | pull.collect(function (err, valid) { 185 | //randomly switch two blocks 186 | var invalid = valid.slice() 187 | //since every even packet is a header, 188 | //moving those will produce valid messages 189 | //but the counters will be wrong. 190 | var i = rand(valid.length/2)*2 191 | var j = rand(valid.length/2)*2 192 | invalid[i] = valid[j] 193 | invalid[i+1] = valid[j+1] 194 | invalid[j] = valid[i] 195 | invalid[j+1] = valid[i+1] 196 | pull( 197 | pull.values(invalid, function () { t.end() }), 198 | boxes.createDecryptStream(key), 199 | pull.collect(function (err, output) { 200 | t.notEqual(output.length, input.length) 201 | t.ok(err) 202 | }) 203 | ) 204 | }) 205 | ) 206 | }) 207 | 208 | tape('detect unexpected hangup', function (t) { 209 | 210 | var input = [ 211 | new Buffer('I <3 TLS\n'), 212 | new Buffer('...\n'), 213 | new Buffer("NOT!!!") 214 | ] 215 | 216 | var key = testKey('detect unexpected hangup') 217 | 218 | pull( 219 | pull.values(input), 220 | boxes.createBoxStream(key), 221 | pull.take(4), //header packet header packet. 222 | boxes.createUnboxStream(key), 223 | pull.collect(function (err, data) { 224 | console.log(err) 225 | t.ok(err) //expects an error 226 | t.equal(data.join(''), 'I <3 TLS\n...\n') 227 | t.end() 228 | }) 229 | ) 230 | 231 | }) 232 | 233 | 234 | tape('detect unexpected hangup, interrupt just the last packet', function (t) { 235 | 236 | var input = [ 237 | new Buffer('I <3 TLS\n'), 238 | new Buffer('...\n'), 239 | new Buffer("NOT!!!") 240 | ] 241 | 242 | var key = testKey('drop hangup packet') 243 | 244 | pull( 245 | pull.values(input), 246 | boxes.createBoxStream(key), 247 | pull.take(6), //header packet header packet. 248 | boxes.createUnboxStream(key), 249 | pull.collect(function (err, data) { 250 | console.log(err) 251 | t.ok(err) //expects an error 252 | t.equal(data.join(''), 'I <3 TLS\n...\nNOT!!!') 253 | t.end() 254 | }) 255 | ) 256 | 257 | }) 258 | 259 | 260 | tape('immediately hangup', function (t) { 261 | 262 | var key = testKey('empty session') 263 | 264 | pull( 265 | pull.values([]), 266 | boxes.createBoxStream(key), 267 | boxes.createUnboxStream(key), 268 | pull.collect(function (err, data) { 269 | t.notOk(err) 270 | t.deepEqual(data, []) 271 | t.end() 272 | }) 273 | ) 274 | 275 | }) 276 | 277 | function stall () { 278 | var _cb 279 | return function (abort, cb) { 280 | console.log('Abort?', abort) 281 | if(abort) { 282 | console.log(abort, _cb, cb) 283 | _cb && _cb(abort) 284 | cb && cb(abort) 285 | } 286 | else _cb = cb 287 | } 288 | 289 | } 290 | 291 | tape('stalled abort', function (t) { 292 | 293 | var key = testKey('stalled abort') 294 | var err = new Error('intentional') 295 | var read = pull(stall(), boxes.createBoxStream(key)) 296 | 297 | var i = 0 298 | read(null, function (_err, data) { 299 | t.equal(_err, err) 300 | t.equal(++i, 1) 301 | }) 302 | 303 | read(err, function () { 304 | t.ok(true) 305 | t.equal(++i, 2) 306 | t.end() 307 | }) 308 | 309 | }) 310 | 311 | tape('stalled abort', function (t) { 312 | 313 | var key = testKey('stalled abort2') 314 | var read = pull(stall(), boxes.createUnboxStream(key)) 315 | var err = new Error('intentional') 316 | 317 | var i = 0 318 | read(null, function (_err, data) { 319 | t.equal(_err, err) 320 | t.equal(++i, 1) 321 | console.log('ended', err, data) 322 | }) 323 | 324 | read(err, function () { 325 | t.ok(true) 326 | t.equal(++i, 2) 327 | t.end() 328 | }) 329 | 330 | }) 331 | 332 | tape('encrypt empty buffers', function (t) { 333 | 334 | var key = testKey('empty') 335 | pull( 336 | pull.values([new Buffer(0)]), 337 | boxes.createBoxStream(key), 338 | boxes.createUnboxStream(key), 339 | pull.collect(function (err, buffers) { 340 | var actual = Buffer.concat(buffers) 341 | t.deepEqual(actual, new Buffer(0)) 342 | t.end() 343 | }) 344 | ) 345 | 346 | }) 347 | 348 | 349 | 350 | 351 | --------------------------------------------------------------------------------