UNPKG

@hyperswarm/secret-stream

Version:

Secret stream backed by Noise and libsodium's secretstream

633 lines (494 loc) 16.6 kB
const { Pull, Push, HEADERBYTES, KEYBYTES, ABYTES } = require('sodium-secretstream') const sodium = require('sodium-universal') const crypto = require('hypercore-crypto') const { Duplex, Writable, getStreamError } = require('streamx') const b4a = require('b4a') const Timeout = require('timeout-refresh') const unslab = require('unslab') const Bridge = require('./lib/bridge') const Handshake = require('./lib/handshake') const IDHEADERBYTES = HEADERBYTES + 32 const [NS_INITIATOR, NS_RESPONDER, NS_SEND] = crypto.namespace('hyperswarm/secret-stream', 3) const MAX_ATOMIC_WRITE = 256 * 256 * 256 - 1 module.exports = class NoiseSecretStream extends Duplex { constructor (isInitiator, rawStream, opts = {}) { super({ mapWritable: toBuffer }) if (typeof isInitiator !== 'boolean') { throw new Error('isInitiator should be a boolean') } this.noiseStream = this this.isInitiator = isInitiator this.rawStream = null this.publicKey = opts.publicKey || null this.remotePublicKey = opts.remotePublicKey || null this.handshakeHash = null this.connected = false this.keepAlive = opts.keepAlive || 0 this.timeout = 0 this.enableSend = opts.enableSend !== false // pointer for upstream to set data here if they want this.userData = null let openedDone = null this.opened = new Promise((resolve) => { openedDone = resolve }) this.rawBytesWritten = 0 this.rawBytesRead = 0 // metadata used by 'hyperdht' this.relay = null this.puncher = null // unwrapped raw stream this._rawStream = null // handshake state this._handshake = null this._handshakePattern = opts.pattern || null this._handshakeDone = null // message parsing state this._state = 0 this._len = 0 this._tmp = 1 this._message = null this._openedDone = openedDone this._startDone = null this._drainDone = null this._outgoingPlain = null this._outgoingWrapped = null this._utp = null this._setup = true this._ended = 2 this._encrypt = null this._decrypt = null this._timeoutTimer = null this._keepAliveTimer = null this._sendState = null if (opts.autoStart !== false) this.start(rawStream, opts) // wiggle it to trigger open immediately (TODO add streamx option for this) this.resume() this.pause() } static keyPair (seed) { return Handshake.keyPair(seed) } static id (handshakeHash, isInitiator, id) { return streamId(handshakeHash, isInitiator, id) } setTimeout (ms) { if (!ms) ms = 0 this._clearTimeout() this.timeout = ms if (!ms || this.rawStream === null) return this._timeoutTimer = Timeout.once(ms, destroyTimeout, this) this._timeoutTimer.unref() } setKeepAlive (ms) { if (!ms) ms = 0 this._clearKeepAlive() this.keepAlive = ms if (!ms || this.rawStream === null) return this._keepAliveTimer = Timeout.on(ms, sendKeepAlive, this) this._keepAliveTimer.unref() } sendKeepAlive () { const empty = this.alloc(0) this.write(empty) } start (rawStream, opts = {}) { if (rawStream) { this.rawStream = rawStream this._rawStream = rawStream if (typeof this.rawStream.setContentSize === 'function') { this._utp = rawStream } } else { this.rawStream = new Bridge(this) this._rawStream = this.rawStream.reverse } this.rawStream.on('error', this._onrawerror.bind(this)) this.rawStream.on('close', this._onrawclose.bind(this)) this._startHandshake(opts.handshake, opts.keyPair || null) this._continueOpen(null) if (this.destroying) return if (opts.data) this._onrawdata(opts.data) if (opts.ended) this._onrawend() if (this.keepAlive > 0 && this._keepAliveTimer === null) { this.setKeepAlive(this.keepAlive) } if (this.timeout > 0 && this._timeoutTimer === null) { this.setTimeout(this.timeout) } } async flush () { if ((await this.opened) === false) return false if ((await Writable.drained(this)) === false) return false if (this.destroying) return false if (this.rawStream !== null && this.rawStream.flush) { return await this.rawStream.flush() } return true } _continueOpen (err) { if (err) this.destroy(err) if (this._startDone === null) return const done = this._startDone this._startDone = null this._open(done) } _onkeypairpromise (p) { const self = this const cont = this._continueOpen.bind(this) p.then(onkeypair, cont) function onkeypair (kp) { self._onkeypair(kp) cont(null) } } _onkeypair (keyPair) { const pattern = this._handshakePattern || 'XX' const remotePublicKey = this.remotePublicKey this._handshake = new Handshake(this.isInitiator, keyPair, remotePublicKey, pattern) this.publicKey = this._handshake.keyPair.publicKey } _startHandshake (handshake, keyPair) { if (handshake) { const { tx, rx, hash, publicKey, remotePublicKey } = handshake this._setupSecretStream(tx, rx, hash, publicKey, remotePublicKey) return } if (!keyPair) keyPair = Handshake.keyPair() if (typeof keyPair.then === 'function') { this._onkeypairpromise(keyPair) } else { this._onkeypair(keyPair) } } _onrawerror (err) { this.destroy(err) } _onrawclose () { if (this._ended !== 0) this.destroy() } _onrawdata (data) { let offset = 0 if (this._timeoutTimer !== null) { this._timeoutTimer.refresh() } do { switch (this._state) { case 0: { while (this._tmp !== 0x1000000 && offset < data.byteLength) { const v = data[offset++] this._len += this._tmp * v this._tmp *= 256 } if (this._tmp === 0x1000000) { this._tmp = 0 this._state = 1 const unprocessed = data.byteLength - offset if (unprocessed < this._len && this._utp !== null) this._utp.setContentSize(this._len - unprocessed) } break } case 1: { const missing = this._len - this._tmp const end = missing + offset if (this._message === null && end <= data.byteLength) { this._message = data.subarray(offset, end) offset += missing this._incoming() break } const unprocessed = data.byteLength - offset if (this._message === null) { this._message = b4a.allocUnsafe(this._len) } b4a.copy(data, this._message, this._tmp, offset) this._tmp += unprocessed if (end <= data.byteLength) { offset += missing this._incoming() } else { offset += unprocessed } break } } } while (offset < data.byteLength && !this.destroying) } _onrawend () { this._ended-- this.push(null) } _onrawdrain () { const drain = this._drainDone if (drain === null) return this._drainDone = null drain() } _read (cb) { this.rawStream.resume() cb(null) } _incoming () { const message = this._message this._state = 0 this._len = 0 this._tmp = 1 this._message = null if (this._setup === true) { if (this._handshake) { this._onhandshakert(this._handshake.recv(message)) } else { if (message.byteLength !== IDHEADERBYTES) { this.destroy(new Error('Invalid header message received')) return } const remoteId = message.subarray(0, 32) const expectedId = streamId(this.handshakeHash, !this.isInitiator) const header = message.subarray(32) if (!b4a.equals(expectedId, remoteId)) { this.destroy(new Error('Invalid header received')) return } this._decrypt.init(header) this._setup = false // setup is now done } return } if (message.byteLength < ABYTES) { this.destroy(new Error('Invalid message received')) return } this.rawBytesRead += message.byteLength const plain = message.subarray(1, message.byteLength - ABYTES + 1) try { this._decrypt.next(message, plain) } catch (err) { this.destroy(err) return } // If keep alive is selective, eat the empty buffers (ie assume the other side has it enabled also) if (plain.byteLength === 0 && this.keepAlive !== 0) return if (this.push(plain) === false) { this.rawStream.pause() } } _onhandshakert (h) { if (this._handshakeDone === null) return if (h !== null) { if (h.data) this._rawStream.write(h.data) if (!h.tx) return } const done = this._handshakeDone const publicKey = this._handshake.keyPair.publicKey this._handshakeDone = null this._handshake = null if (h === null) return done(new Error('Noise handshake failed')) this._setupSecretStream(h.tx, h.rx, h.hash, publicKey, h.remotePublicKey) this._resolveOpened(true) done(null) } _setupSecretStream (tx, rx, handshakeHash, publicKey, remotePublicKey) { const buf = b4a.allocUnsafeSlow(3 + IDHEADERBYTES) writeUint24le(IDHEADERBYTES, buf) this._encrypt = new Push(unslab(tx.subarray(0, KEYBYTES)), undefined, buf.subarray(3 + 32)) this._decrypt = new Pull(unslab(rx.subarray(0, KEYBYTES))) this.publicKey = publicKey this.remotePublicKey = remotePublicKey this.handshakeHash = handshakeHash const id = buf.subarray(3, 3 + 32) streamId(handshakeHash, this.isInitiator, id) // initialize secretbox state for unordered messages this._setupSecretSend(handshakeHash) this.emit('handshake') // if rawStream is a bridge, also emit it there if (this.rawStream !== this._rawStream) this.rawStream.emit('handshake') if (this.destroying) return this._rawStream.write(buf) } _setupSecretSend (handshakeHash) { this._sendState = b4a.allocUnsafeSlow(32 + 32 + 8 + 8) const encrypt = this._sendState.subarray(0, 32) // secrets const decrypt = this._sendState.subarray(32, 64) const counter = this._sendState.subarray(64, 72) // nonce const initial = this._sendState.subarray(72) const inputs = this.isInitiator ? [[NS_INITIATOR, NS_SEND], [NS_RESPONDER, NS_SEND]] : [[NS_RESPONDER, NS_SEND], [NS_INITIATOR, NS_SEND]] sodium.crypto_generichash_batch(encrypt, inputs[0], handshakeHash) sodium.crypto_generichash_batch(decrypt, inputs[1], handshakeHash) sodium.randombytes_buf(initial) counter.set(initial) } _open (cb) { // no autostart or no handshake yet if (this._rawStream === null || (this._handshake === null && this._encrypt === null)) { this._startDone = cb return } this._rawStream.on('data', this._onrawdata.bind(this)) this._rawStream.on('end', this._onrawend.bind(this)) this._rawStream.on('drain', this._onrawdrain.bind(this)) if (this.enableSend) this._rawStream.on('message', this._onmessage.bind(this)) if (this._encrypt !== null) { this._resolveOpened(true) return cb(null) } this._handshakeDone = cb if (this.isInitiator) this._onhandshakert(this._handshake.send()) } _predestroy () { if (this.rawStream) { const error = getStreamError(this) this.rawStream.destroy(error) } if (this._startDone !== null) { const done = this._startDone this._startDone = null done(new Error('Stream destroyed')) } if (this._handshakeDone !== null) { const done = this._handshakeDone this._handshakeDone = null done(new Error('Stream destroyed')) } if (this._drainDone !== null) { const done = this._drainDone this._drainDone = null done(new Error('Stream destroyed')) } } _write (data, cb) { let wrapped = this._outgoingWrapped if (data !== this._outgoingPlain) { wrapped = b4a.allocUnsafe(data.byteLength + 3 + ABYTES) wrapped.set(data, 4) } else { this._outgoingWrapped = this._outgoingPlain = null } if (wrapped.byteLength - 3 > MAX_ATOMIC_WRITE) { return cb(new Error('Message is too large for an atomic write. Max size is ' + MAX_ATOMIC_WRITE + ' bytes.')) } this.rawBytesWritten += wrapped.byteLength writeUint24le(wrapped.byteLength - 3, wrapped) // offset 4 so we can do it in-place this._encrypt.next(wrapped.subarray(4, 4 + data.byteLength), wrapped.subarray(3)) if (this._keepAliveTimer !== null) this._keepAliveTimer.refresh() if (this._rawStream.write(wrapped) === false) { this._drainDone = cb } else { cb(null) } } _final (cb) { this._clearKeepAlive() this._ended-- this._rawStream.end() cb(null) } _resolveOpened (val) { if (this._openedDone === null) return const opened = this._openedDone this._openedDone = null opened(val) if (!val) return this.connected = true this.emit('connect') } _clearTimeout () { if (this._timeoutTimer === null) return this._timeoutTimer.destroy() this._timeoutTimer = null this.timeout = 0 } _clearKeepAlive () { if (this._keepAliveTimer === null) return this._keepAliveTimer.destroy() this._keepAliveTimer = null this.keepAlive = 0 } _destroy (cb) { this._clearKeepAlive() this._clearTimeout() this._resolveOpened(false) cb(null) } _boxMessage (buffer) { const MB = sodium.crypto_secretbox_MACBYTES // 16 const NB = sodium.crypto_secretbox_NONCEBYTES // 24 const counter = this._sendState.subarray(64, 72) sodium.sodium_increment(counter) if (b4a.equals(counter, this._sendState.subarray(72))) { this.destroy(new Error('udp send nonce exchausted')) return } const secret = this._sendState.subarray(0, 32) const envelope = b4a.allocUnsafe(8 + MB + buffer.byteLength) const nonce = envelope.subarray(0, NB) const ciphertext = envelope.subarray(8) b4a.fill(nonce, 0) // pad suffix nonce.set(counter) sodium.crypto_secretbox_easy(ciphertext, buffer, nonce, secret) return envelope } send (buffer) { if (!this._sendState) return if (!this.rawStream?.send) return // udx-stream expected const message = this._boxMessage(buffer) return this.rawStream.send(message) } trySend (buffer) { if (!this._sendState) return if (!this.rawStream?.trySend) return // udx-stream expected const message = this._boxMessage(buffer) this.rawStream.trySend(message) } _onmessage (buffer) { if (!this._sendState) return // messages before handshake are dropped const MB = sodium.crypto_secretbox_MACBYTES // 16 const NB = sodium.crypto_secretbox_NONCEBYTES // 24 if (buffer.byteLength < NB) return // Invalid message const nonce = b4a.allocUnsafe(NB) b4a.fill(nonce, 0) nonce.set(buffer.subarray(0, 8)) const secret = this._sendState.subarray(32, 64) const ciphertext = buffer.subarray(8) const plain = buffer.subarray(8, buffer.byteLength - MB) if (ciphertext.byteLength < MB) return // invalid message const success = sodium.crypto_secretbox_open_easy(plain, ciphertext, nonce, secret) if (success) this.emit('message', plain) } alloc (len) { const buf = b4a.allocUnsafe(len + 3 + ABYTES) this._outgoingWrapped = buf this._outgoingPlain = buf.subarray(4, buf.byteLength - ABYTES + 1) return this._outgoingPlain } toJSON () { return { isInitiator: this.isInitiator, publicKey: this.publicKey && b4a.toString(this.publicKey, 'hex'), remotePublicKey: this.remotePublicKey && b4a.toString(this.remotePublicKey, 'hex'), connected: this.connected, destroying: this.destroying, destroyed: this.destroyed, rawStream: this.rawStream && this.rawStream.toJSON ? this.rawStream.toJSON() : null } } } function writeUint24le (n, buf) { buf[0] = (n & 255) buf[1] = (n >>> 8) & 255 buf[2] = (n >>> 16) & 255 } function streamId (handshakeHash, isInitiator, out = b4a.allocUnsafe(32)) { sodium.crypto_generichash(out, isInitiator ? NS_INITIATOR : NS_RESPONDER, handshakeHash) return out } function toBuffer (data) { return typeof data === 'string' ? b4a.from(data) : data } function destroyTimeout () { this.destroy(new Error('Stream timed out')) } function sendKeepAlive () { const empty = this.alloc(0) this.write(empty) }