/*!
 * bip151.js - peer-to-peer communication encryption.
 * Copyright (c) 2016-2017, Christopher Jeffrey (MIT License).
 * https://github.com/bcoin-org/bcoin
 * Resources:
 *   https://github.com/bitcoin/bips/blob/master/bip-0151.mediawiki
 *   https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.chacha20poly1305
 *   https://github.com/openssh/openssh-portable/blob/master/cipher-chachapoly.c
 *   https://github.com/openssh/openssh-portable/blob/master/cipher.c
 *   https://github.com/openssh/openssh-portable/blob/master/packet.c
 */

'use strict';

var EventEmitter = require('events').EventEmitter;
var util = require('../utils/util');
var co = require('../utils/co');
var crypto = require('../crypto/crypto');
var assert = require('assert');
var chachapoly = require('../crypto/chachapoly');
var packets = require('./packets');
var ec = require('../crypto/ec');
var StaticWriter = require('../utils/staticwriter');
var BufferReader = require('../utils/reader');
var encoding = require('../utils/encoding');
var EncinitPacket = packets.EncinitPacket;
var EncackPacket = packets.EncackPacket;

/*
 * Constants
 */

var HKDF_SALT = new Buffer('bitcoinecdh', 'ascii');
var INFO_KEY1 = new Buffer('BitcoinK1', 'ascii');
var INFO_KEY2 = new Buffer('BitcoinK2', 'ascii');
var INFO_SID = new Buffer('BitcoinSessionID', 'ascii');
var HIGH_WATERMARK = 1024 * (1 << 20);

/**
 * Represents a BIP151 input or output stream.
 * @alias module:net.BIP151Stream
 * @constructor
 * @param {Number} cipher
 * @property {Buffer} publicKey
 * @property {Buffer} privateKey
 * @property {Number} cipher
 * @property {Buffer} prk
 * @property {Buffer} k1
 * @property {Buffer} k2
 * @property {Buffer} sid
 * @property {ChaCha20} chacha
 * @property {AEAD} aead
 * @property {Buffer} tag
 * @property {Number} seq
 * @property {Number} processed
 * @property {Number} lastKey
 */

function BIP151Stream(cipher) {
  if (!(this instanceof BIP151Stream))
    return new BIP151Stream(cipher);

  this.cipher = BIP151.ciphers.CHACHAPOLY;
  this.privateKey = ec.generatePrivateKey();
  this.publicKey = null;
  this.secret = null;
  this.prk = null;
  this.k1 = null;
  this.k2 = null;
  this.sid = null;

  if (cipher != null) {
    assert(cipher === BIP151.ciphers.CHACHAPOLY, 'Unknown cipher type.');
    this.cipher = cipher;
  }

  this.chacha = new chachapoly.ChaCha20();
  this.aead = new chachapoly.AEAD();
  this.tag = null;
  this.seq = 0;
  this.iv = new Buffer(8);
  this.iv.fill(0);

  this.processed = 0;
  this.lastRekey = 0;
}

/**
 * Initialize the stream with peer's public key.
 * Computes ecdh secret and chacha keys.
 * @param {Buffer} publicKey
 */

BIP151Stream.prototype.init = function init(publicKey) {
  var bw = new StaticWriter(33);

  this.publicKey = publicKey;
  this.secret = ec.ecdh(this.publicKey, this.privateKey);

  bw.writeBytes(this.secret);
  bw.writeU8(this.cipher);

  this.prk = crypto.hkdfExtract(bw.render(), HKDF_SALT, 'sha256');
  this.k1 = crypto.hkdfExpand(this.prk, INFO_KEY1, 32, 'sha256');
  this.k2 = crypto.hkdfExpand(this.prk, INFO_KEY2, 32, 'sha256');
  this.sid = crypto.hkdfExpand(this.prk, INFO_SID, 32, 'sha256');

  this.seq = 0;

  this.update();

  this.chacha.init(this.k1, this.iv);
  this.aead.init(this.k2, this.iv);

  this.lastRekey = util.now();
};

/**
 * Add buffer size to `processed`,
 * check whether we need to rekey.
 * @param {Buffer} packet
 * @returns {Boolean}
 */

BIP151Stream.prototype.shouldRekey = function shouldRekey(packet) {
  var now = util.now();

  this.processed += packet.length;

  if (now >= this.lastRekey + 10
      || this.processed >= HIGH_WATERMARK) {
    this.lastRekey = now;
    this.processed = 0;
    return true;
  }

  return false;
};

/**
 * Generate new chacha keys with `key = HASH256(sid | key)`.
 * This will reinitialize the state of both ciphers.
 */

BIP151Stream.prototype.rekey = function rekey(k1, k2) {
  var seed;

  assert(this.prk, 'Cannot rekey before initialization.');

  if (!k1) {
    seed = new Buffer(64);

    this.sid.copy(seed, 0);

    this.k1.copy(seed, 32);
    this.k1 = crypto.hash256(seed);

    this.k2.copy(seed, 32);
    this.k2 = crypto.hash256(seed);
  } else {
    this.k1 = k1;
    this.k2 = k2;
  }

  assert(this.k1);
  assert(this.k2);

  // All state is reinitialized
  // aside from the sequence number.
  this.chacha.init(this.k1, this.iv);
  this.aead.init(this.k2, this.iv);
};

/**
 * Increment packet sequence number and update IVs
 * (note, sequence number overflows after 2^64-1).
 * The IV will be updated without reinitializing
 * cipher state.
 */

BIP151Stream.prototype.sequence = function sequence() {
  // Wrap sequence number a la openssh.
  if (++this.seq === 0x100000000)
    this.seq = 0;

  this.update();

  // State of the ciphers is
  // unaltered aside from the iv.
  this.chacha.init(null, this.iv);
  this.aead.init(null, this.iv);
};

/**
 * Render the IV necessary for cipher streams.
 * @returns {Buffer}
 */

BIP151Stream.prototype.update = function update() {
  this.iv.writeUInt32LE(this.seq, 0, true);
  return this.iv;
};

/**
 * Get public key tied to private key
 * (not the same as BIP151Stream#publicKey).
 * @returns {Buffer}
 */

BIP151Stream.prototype.getPublicKey = function getPublicKey() {
  return ec.publicKeyCreate(this.privateKey, true);
};

/**
 * Encrypt a payload size with k1.
 * @param {Buffer} data
 * @returns {Buffer}
 */

BIP151Stream.prototype.encryptSize = function encryptSize(data) {
  return this.chacha.encrypt(data.slice(0, 4));
};

/**
 * Decrypt payload size with k1.
 * @param {Buffer} data
 * @returns {Number}
 */

BIP151Stream.prototype.decryptSize = function decryptSize(data) {
  this.chacha.encrypt(data);
  return data.readUInt32LE(0, true);
};

/**
 * Encrypt payload with AEAD (update cipher and mac).
 * @param {Buffer} data
 * @returns {Buffer} data
 */

BIP151Stream.prototype.encrypt = function encrypt(data) {
  return this.aead.encrypt(data);
};

/**
 * Decrypt payload with AEAD (update cipher only).
 * @param {Buffer} data
 * @returns {Buffer} data
 */

BIP151Stream.prototype.decrypt = function decrypt(data) {
  return this.aead.chacha20.encrypt(data);
};

/**
 * Authenticate payload with AEAD (update mac only).
 * @param {Buffer} data
 * @returns {Buffer} data
 */

BIP151Stream.prototype.auth = function auth(data) {
  return this.aead.auth(data);
};

/**
 * Finalize AEAD and compute MAC.
 * @returns {Buffer}
 */

BIP151Stream.prototype.finish = function finish() {
  this.tag = this.aead.finish();
  return this.tag;
};

/**
 * Verify tag against mac in constant time.
 * @param {Buffer} tag
 * @returns {Boolean}
 */

BIP151Stream.prototype.verify = function verify(tag) {
  return chachapoly.Poly1305.verify(this.tag, tag);
};

/**
 * Represents a BIP151 input and output stream.
 * Holds state for peer communication.
 * @alias module:net.BIP151
 * @constructor
 * @param {Number} cipher
 * @property {BIP151Stream} input
 * @property {BIP151Stream} output
 * @property {Boolean} initReceived
 * @property {Boolean} ackReceived
 * @property {Boolean} initSent
 * @property {Boolean} ackSent
 * @property {Object} timeout
 * @property {Job} job
 * @property {Boolean} completed
 * @property {Boolean} handshake
 */

function BIP151(cipher) {
  if (!(this instanceof BIP151))
    return new BIP151(cipher);

  EventEmitter.call(this);

  this.input = new BIP151Stream(cipher);
  this.output = new BIP151Stream(cipher);

  this.initReceived = false;
  this.ackReceived = false;
  this.initSent = false;
  this.ackSent = false;
  this.completed = false;
  this.handshake = false;

  this.pending = [];
  this.total = 0;
  this.waiting = 4;
  this.hasSize = false;

  this.timeout = null;
  this.job = null;
  this.onShake = null;

  this.bip150 = null;
}

util.inherits(BIP151, EventEmitter);

/**
 * Cipher list.
 * @enum {Number}
 */

BIP151.ciphers = {
  CHACHAPOLY: 0
};

/**
 * Max message size.
 * @const {Number}
 * @default
 */

BIP151.MAX_MESSAGE = 12 * 1000 * 1000;

/**
 * Emit an error.
 * @param {...String} msg
 */

BIP151.prototype.error = function error() {
  var msg = util.fmt.apply(util, arguments);
  this.emit('error', new Error(msg));
};

/**
 * Test whether handshake has completed.
 * @returns {Boolean}
 */

BIP151.prototype.isReady = function isReady() {
  return this.initSent
    && this.ackReceived
    && this.initReceived
    && this.ackSent;
};

/**
 * Render an `encinit` packet. Contains the
 * input public key and cipher number.
 * @returns {Buffer}
 */

BIP151.prototype.toEncinit = function toEncinit() {
  assert(!this.initSent, 'Cannot init twice.');
  this.initSent = true;
  return new EncinitPacket(this.input.getPublicKey(), this.input.cipher);
};

/**
 * Render `encack` packet. Contains the
 * output stream public key.
 * @returns {Buffer}
 */

BIP151.prototype.toEncack = function toEncack() {
  assert(this.output.prk, 'Cannot ack before init.');
  assert(!this.ackSent, 'Cannot ack twice.');
  this.ackSent = true;

  if (this.isReady()) {
    assert(!this.completed, 'No encack after timeout.');
    this.handshake = true;
    this.emit('handshake');
  }

  return new EncackPacket(this.output.getPublicKey());
};

/**
 * Render `encack` packet with an all
 * zero public key, notifying of a rekey
 * for the output stream.
 * @returns {Buffer}
 */

BIP151.prototype.toRekey = function toRekey() {
  assert(this.handshake, 'Cannot rekey before handshake.');
  return new EncackPacket(encoding.ZERO_KEY);
};

/**
 * Handle `encinit` from remote peer.
 * @param {Buffer}
 */

BIP151.prototype.encinit = function encinit(publicKey, cipher) {
  assert(cipher === this.output.cipher, 'Cipher mismatch.');
  assert(!this.initReceived, 'Already initialized.');
  assert(!this.completed, 'No encinit after timeout.');
  this.initReceived = true;
  this.output.init(publicKey);
};

/**
 * Handle `encack` from remote peer.
 * @param {Buffer} data
 */

BIP151.prototype.encack = function encack(publicKey) {
  assert(this.initSent, 'Unsolicited ACK.');

  if (util.equal(publicKey, encoding.ZERO_KEY)) {
    assert(this.handshake, 'No initialization before rekey.');

    if (this.bip150 && this.bip150.auth) {
      this.bip150.rekeyInput();
      return;
    }

    this.input.rekey();

    return;
  }

  assert(!this.ackReceived, 'Already ACKed.');
  assert(!this.completed, 'No encack after timeout.');
  this.ackReceived = true;

  this.input.init(publicKey);

  if (this.isReady()) {
    this.handshake = true;
    this.emit('handshake');
  }
};

/**
 * Cleanup handshake job.
 * @returns {Job}
 */

BIP151.prototype.cleanup = function cleanup() {
  var job = this.job;

  assert(!this.completed, 'Already completed.');
  assert(job, 'No completion job.');

  this.completed = true;
  this.job = null;

  if (this.timeout != null) {
    clearTimeout(this.timeout);
    this.timeout = null;
  }

  if (this.onShake) {
    this.removeListener('handshake', this.onShake);
    this.onShake = null;
  }

  return job;
};

/**
 * Complete the timeout for handshake.
 * @param {Object} result
 */

BIP151.prototype.resolve = function resolve(result) {
  var job = this.cleanup();
  job.resolve(result);
};

/**
 * Complete the timeout for handshake with error.
 * @param {Error} err
 */

BIP151.prototype.reject = function reject(err) {
  var job = this.cleanup();
  job.reject(err);
};

/**
 * Set a timeout and wait for handshake to complete.
 * @param {Number} timeout - Timeout in ms.
 * @returns {Promise}
 */

BIP151.prototype.wait = function wait(timeout) {
  var self = this;
  return new Promise(function(resolve, reject) {
    self._wait(timeout, resolve, reject);
  });
};

/**
 * Set a timeout and wait for handshake to complete.
 * @private
 * @param {Number} timeout
 * @param {Function} resolve
 * @param {Function} reject
 */

BIP151.prototype._wait = function wait(timeout, resolve, reject) {
  var self = this;

  assert(!this.handshake, 'Cannot wait for init after handshake.');

  this.job = co.job(resolve, reject);

  this.timeout = setTimeout(function() {
    self.reject(new Error('BIP151 handshake timed out.'));
  }, timeout);

  this.onShake = this.resolve.bind(this);
  this.once('handshake', this.onShake);
};

/**
 * Destroy BIP151 state and streams.
 */

BIP151.prototype.destroy = function destroy() {
  if (!this.job)
    return;

  this.reject(new Error('BIP151 stream was destroyed.'));
};

/**
 * Add buffer size to `processed`,
 * check whether we need to rekey.
 * @param {Buffer} packet
 */

BIP151.prototype.maybeRekey = function maybeRekey(packet) {
  if (!this.output.shouldRekey(packet))
    return;

  this.emit('rekey');

  if (this.bip150 && this.bip150.auth) {
    this.bip150.rekeyOutput();
    return;
  }

  this.output.rekey();
};

/**
 * Calculate packet size.
 * @param {String} cmd
 * @param {Buffer} body
 * @returns {Number}
 */

BIP151.prototype.packetSize = function packetSize(cmd, body) {
  var size = 0;
  size += 4;
  size += encoding.sizeVarString(cmd, 'ascii');
  size += 4;
  size += body.length;
  size += 16;
  return size;
};

/**
 * Frame plaintext payload for the output stream.
 * @param {String} cmd
 * @param {Buffer} body
 * @returns {Buffer} Ciphertext payload
 */

BIP151.prototype.packet = function packet(cmd, body) {
  var size = this.packetSize(cmd, body);
  var bw = new StaticWriter(size);
  var payloadSize = size - 20;
  var packet, payload;

  bw.writeU32(payloadSize);
  bw.writeVarString(cmd, 'ascii');
  bw.writeU32(body.length);
  bw.writeBytes(body);
  bw.seek(16);

  packet = bw.render();
  payload = packet.slice(4, 4 + payloadSize);

  this.maybeRekey(packet);

  this.output.encryptSize(packet);
  this.output.encrypt(payload);
  this.output.finish().copy(packet, 4 + payloadSize);
  this.output.sequence();

  return packet;
};

/**
 * Feed ciphertext payload chunk
 * to the input stream. Potentially
 * emits a `packet` event.
 * @param {Buffer} data
 */

BIP151.prototype.feed = function feed(data) {
  var chunk;

  this.total += data.length;
  this.pending.push(data);

  while (this.total >= this.waiting) {
    chunk = this.read(this.waiting);
    this.parse(chunk);
  }
};

/**
 * Read and consume a number of bytes
 * from the buffered stream.
 * @param {Number} size
 * @returns {Buffer}
 */

BIP151.prototype.read = function read(size) {
  var pending, chunk, off, len;

  assert(this.total >= size, 'Reading too much.');

  if (size === 0)
    return new Buffer(0);

  pending = this.pending[0];

  if (pending.length > size) {
    chunk = pending.slice(0, size);
    this.pending[0] = pending.slice(size);
    this.total -= chunk.length;
    return chunk;
  }

  if (pending.length === size) {
    chunk = this.pending.shift();
    this.total -= chunk.length;
    return chunk;
  }

  chunk = new Buffer(size);
  off = 0;
  len = 0;

  while (off < chunk.length) {
    pending = this.pending[0];
    len = pending.copy(chunk, off);
    if (len === pending.length)
      this.pending.shift();
    else
      this.pending[0] = pending.slice(len);
    off += len;
  }

  assert.equal(off, chunk.length);

  this.total -= chunk.length;

  return chunk;
};

/**
 * Parse a ciphertext payload chunk.
 * Potentially emits a `packet` event.
 * @param {Buffer} data
 */

BIP151.prototype.parse = function parse(data) {
  var size, payload, tag, br, cmd, body;

  if (!this.hasSize) {
    size = this.input.decryptSize(data);

    assert(this.waiting === 4);
    assert(data.length === 4);

    // Allow 3 batched packets of max message size (12mb).
    // Not technically standard, but this protects us
    // from buffering tons of data due to either an
    // potential dos'er or a cipher state mismatch.
    // Note that 6 is the minimum size:
    // varint-cmdlen(1) str-cmd(1) u32-size(4) payload(0)
    if (size < 6 || size > BIP151.MAX_MESSAGE) {
      this.error('Bad packet size: %d.', util.mb(size));
      return;
    }

    this.hasSize = true;
    this.waiting = size + 16;

    return;
  }

  payload = data.slice(0, this.waiting - 16);
  tag = data.slice(this.waiting - 16, this.waiting);

  this.hasSize = false;
  this.waiting = 4;

  // Authenticate payload before decrypting.
  // This ensures the cipher state isn't altered
  // if the payload integrity has been compromised.
  this.input.auth(payload);
  this.input.finish();

  if (!this.input.verify(tag)) {
    this.input.sequence();
    this.error('Bad tag: %s.', tag.toString('hex'));
    return;
  }

  this.input.decrypt(payload);
  this.input.sequence();

  br = new BufferReader(payload);

  while (br.left()) {
    try {
      cmd = br.readVarString('ascii');
      body = br.readBytes(br.readU32());
    } catch (e) {
      this.emit('error', e);
      return;
    }

    this.emit('packet', cmd, body);
  }
};

/*
 * Expose
 */

module.exports = BIP151;
