/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

const Cookies = require("cookies");

const crypto = require("crypto");
const util = require("util");


const COOKIE_NAME_SEP = '=';
const ACTIVE_DURATION = 1000 * 60 * 5;

const KDF_ENC = 'cookiesession-encryption';
const KDF_MAC = 'cookiesession-signature';

/* map from cipher algorithm to exact key byte length */
const ENCRYPTION_ALGORITHMS = {
  aes128: 16, // implicit CBC mode
  aes192: 24,
  aes256: 32
};
const DEFAULT_ENCRYPTION_ALGO = 'aes256';

/* map from hmac algorithm to _minimum_ key byte length */
const SIGNATURE_ALGORITHMS = {
  'sha256': 32,
  'sha256-drop128': 32,
  'sha384': 48,
  'sha384-drop192': 48,
  'sha512': 64,
  'sha512-drop256': 64
 };
const DEFAULT_SIGNATURE_ALGO = 'sha256';

function isObject(val) {
  return Object.prototype.toString.call(val) === '[object Object]';
}

function base64urlencode(arg) {
  var s = arg.toString('base64');
  s = s.split('=')[0]; // Remove any trailing '='s
  s = s.replace(/\+/g, '-'); // 62nd char of encoding
  s = s.replace(/\//g, '_'); // 63rd char of encoding
  // TODO optimize this; we can do much better
  return s;
}

function base64urldecode(arg) {
  var s = arg;
  s = s.replace(/-/g, '+'); // 62nd char of encoding
  s = s.replace(/_/g, '/'); // 63rd char of encoding
  switch (s.length % 4) { // Pad with trailing '='s
    case 0:
      break; // No pad chars in this case
    case 2:
      s += "==";
      break; // Two pad chars
    case 3:
      s += "=";
      break; // One pad char
    default:
      throw new Error("Illegal base64url string!");
  }
  return new Buffer(s, 'base64'); // Standard base64 decoder
}

function forceBuffer(binaryOrBuffer) {
  if (Buffer.isBuffer(binaryOrBuffer)) {
    return binaryOrBuffer;
  } else {
    return new Buffer(binaryOrBuffer, 'binary');
  }
}

function deriveKey(master, type) {
  // eventually we want to use HKDF. For now we'll do something simpler.
  var hmac = crypto.createHmac('sha256', master);
  hmac.update(type);
  return forceBuffer(hmac.digest());
}

function setupKeys(opts) {
  // derive two keys, one for signing one for encrypting, from the secret.
  if (!opts.encryptionKey) {
    opts.encryptionKey = deriveKey(opts.secret, KDF_ENC);
  }

  if (!opts.signatureKey) {
    opts.signatureKey = deriveKey(opts.secret, KDF_MAC);
  }

  if (!opts.signatureAlgorithm) {
    opts.signatureAlgorithm = DEFAULT_SIGNATURE_ALGO;
  }

  if (!opts.encryptionAlgorithm) {
    opts.encryptionAlgorithm = DEFAULT_ENCRYPTION_ALGO;
  }
}

function keyConstraints(opts) {
  if (!Buffer.isBuffer(opts.encryptionKey)) {
    throw new Error('encryptionKey must be a Buffer');
  }
  if (!Buffer.isBuffer(opts.signatureKey)) {
    throw new Error('signatureKey must be a Buffer');
  }

  if (constantTimeEquals(opts.encryptionKey, opts.signatureKey)) {
    throw new Error('Encryption and Signature keys must be different');
  }

  var encAlgo = opts.encryptionAlgorithm;
  var required = ENCRYPTION_ALGORITHMS[encAlgo];
  if (opts.encryptionKey.length !== required) {
    throw new Error(
      'Encryption Key for '+encAlgo+' must be exactly '+required+' bytes '+
      '('+(required*8)+' bits)'
    );
  }

  var sigAlgo = opts.signatureAlgorithm;
  var minimum = SIGNATURE_ALGORITHMS[sigAlgo];
  if (opts.signatureKey.length < minimum) {
    throw new Error(
      'Encryption Key for '+sigAlgo+' must be at least '+minimum+' bytes '+
      '('+(minimum*8)+' bits)'
    );
  }
}

function constantTimeEquals(a, b) {
  // Ideally this would be a native function, so it's less sensitive to how the
  // JS engine might optimize.
  if (a.length !== b.length) {
    return false;
  }
  var ret = 0;
  for (var i = 0; i < a.length; i++) {
    ret |= a.readUInt8(i) ^ b.readUInt8(i);
  }
  return ret === 0;
}

// it's good cryptographic pracitice to not leave buffers with sensitive
// contents hanging around.
function zeroBuffer(buf) {
  for (var i = 0; i < buf.length; i++) {
    buf[i] = 0;
  }
  return buf;
}

function hmacInit(algo, key) {
  var match = algo.match(/^([^-]+)(?:-drop(\d+))?$/);
  var baseAlg = match[1];
  var drop = match[2] ? parseInt(match[2], 10) : 0;

  var hmacAlg = crypto.createHmac(baseAlg, key);
  var origDigest = hmacAlg.digest;

  if (drop === 0) {
    // Before 0.10, crypto returns binary-encoded strings. Remove when dropping
    // 0.8 support.
    hmacAlg.digest = function() {
      return forceBuffer(origDigest.call(this));
    };
  } else {
    var N = drop / 8; // bits to bytes
    hmacAlg.digest = function dropN() {
      var result = forceBuffer(origDigest.call(this));
      // Throw away the second half of the 512-bit result, leaving the first
      // 256-bits.
      var truncated = new Buffer(N);
      result.copy(truncated, 0, 0, N);
      zeroBuffer(result);
      return truncated;
    };
  }

  return hmacAlg;
}

function computeHmac(opts, iv, ciphertext, duration, createdAt) {
  var hmacAlg = hmacInit(opts.signatureAlgorithm, opts.signatureKey);

  hmacAlg.update(iv);
  hmacAlg.update(".");
  hmacAlg.update(ciphertext);
  hmacAlg.update(".");
  hmacAlg.update(createdAt.toString());
  hmacAlg.update(".");
  hmacAlg.update(duration.toString());

  return hmacAlg.digest();
}

function encode(opts, content, duration, createdAt){
  // format will be:
  // iv.ciphertext.createdAt.duration.hmac

  if (!opts.cookieName) {
    throw new Error('cookieName option required');
  } else if (String(opts.cookieName).indexOf(COOKIE_NAME_SEP) !== -1) {
    throw new Error('cookieName cannot include "="');
  }

  setupKeys(opts);

  duration = duration || 24*60*60*1000;
  createdAt = createdAt || new Date().getTime();

  // generate iv
  var iv = crypto.randomBytes(16);

  // encrypt with encryption key
  var plaintext = new Buffer(
    opts.cookieName + COOKIE_NAME_SEP + JSON.stringify(content),
    'utf8'
  );
  var cipher = crypto.createCipheriv(
    opts.encryptionAlgorithm,
    opts.encryptionKey,
    iv
  );

  var ciphertextStart = forceBuffer(cipher.update(plaintext));
  zeroBuffer(plaintext);
  var ciphertextEnd = forceBuffer(cipher.final());
  var ciphertext = Buffer.concat([ciphertextStart, ciphertextEnd]);
  zeroBuffer(ciphertextStart);
  zeroBuffer(ciphertextEnd);

  // hmac it
  var hmac = computeHmac(opts, iv, ciphertext, duration, createdAt);

  var result = [
    base64urlencode(iv),
    base64urlencode(ciphertext),
    createdAt,
    duration,
    base64urlencode(hmac)
  ].join('.');

  zeroBuffer(iv);
  zeroBuffer(ciphertext);
  zeroBuffer(hmac);

  return result;
}

function decode(opts, content) {
  if (!opts.cookieName) {
    throw new Error("cookieName option required");
  }

  // stop at any time if there's an issue
  var components = content.split(".");
  if (components.length !== 5) {
    return;
  }

  setupKeys(opts);

  var iv;
  var ciphertext;
  var hmac;

  try {
    iv = base64urldecode(components[0]);
    ciphertext = base64urldecode(components[1]);
    hmac = base64urldecode(components[4]);
  } catch (ignored) {
    cleanup();
    return;
  }

  var createdAt = parseInt(components[2], 10);
  var duration = parseInt(components[3], 10);

  function cleanup() {
    if (iv) {
      zeroBuffer(iv);
    }

    if (ciphertext) {
      zeroBuffer(ciphertext);
    }

    if (hmac) {
      zeroBuffer(hmac);
    }

    if (expectedHmac) { // declared below
      zeroBuffer(expectedHmac);
    }
  }

  // make sure IV is right length
  if (iv.length !== 16) {
    cleanup();
    return;
  }

  // check hmac
  var expectedHmac = computeHmac(opts, iv, ciphertext, duration, createdAt);

  if (!constantTimeEquals(hmac, expectedHmac)) {
    cleanup();
    return;
  }

  // decrypt
  var cipher = crypto.createDecipheriv(
    opts.encryptionAlgorithm,
    opts.encryptionKey,
    iv
  );
  var plaintext = cipher.update(ciphertext, 'binary', 'utf8');
  plaintext += cipher.final('utf8');

  var cookieName = plaintext.substring(0, plaintext.indexOf(COOKIE_NAME_SEP));
  if (cookieName !== opts.cookieName) {
    cleanup();
    return;
  }

  var result;
  try {
    result = {
      content: JSON.parse(
        plaintext.substring(plaintext.indexOf(COOKIE_NAME_SEP) + 1)
      ),
      createdAt: createdAt,
      duration: duration
    };
  } catch (ignored) {
  }

  cleanup();
  return result;
}

/*
 * Session object
 *
 * this should be implemented with proxies at some point
 */
function Session(req, res, cookies, opts) {
  this.req = req;
  this.res = res;
  this.cookies = cookies;
  this.opts = opts;
  if (opts.cookie.ephemeral && opts.cookie.maxAge) {
    throw new Error("you cannot have an ephemeral cookie with a maxAge.");
  }

  this.content = {};
  this.json = JSON.stringify(this._content);
  this.loaded = false;
  this.dirty = false;

  // no need to initialize it, loadFromCookie will do
  // via reset() or unbox()
  this.createdAt = null;
  this.duration = opts.duration;
  this.activeDuration = opts.activeDuration;

  // support for maxAge
  if (opts.cookie.maxAge) {
    this.expires = new Date(new Date().getTime() + opts.cookie.maxAge);
  } else {
    this.updateDefaultExpires();
  }

  // here, we check that the security bits are set correctly
  var secure = (res.socket && res.socket.encrypted) ||
      (req.connection && req.connection.proxySecure);
  if (opts.cookie.secure && !secure) {
    throw new Error("you cannot have a secure cookie unless the socket is " +
        " secure or you declare req.connection.proxySecure to be true.");
  }
}

Session.prototype = {
  updateDefaultExpires: function() {
    if (this.opts.cookie.maxAge) {
      return;
    }

    if (this.opts.cookie.ephemeral) {
      this.expires = null;
    } else {
      var time = this.createdAt || new Date().getTime();
      // the cookie should expire when it becomes invalid
      // we add an extra second because the conversion to a date
      // truncates the milliseconds
      this.expires = new Date(time + this.duration + 1000);
    }
  },

  clearContent: function(keysToPreserve) {
    var self = this;
    Object.keys(this._content).forEach(function(k) {
      // exclude this key if it's meant to be preserved
      if (keysToPreserve && (keysToPreserve.indexOf(k) > -1)) {
        return;
      }

      delete self._content[k];
    });
  },

  reset: function(keysToPreserve) {
    this.clearContent(keysToPreserve);
    this.createdAt = new Date().getTime();
    this.duration = this.opts.duration;
    this.updateDefaultExpires();
    this.dirty = true;
    this.loaded = true;
  },

  // alias for `reset` function for compatibility
  destroy: function(){
    this.reset();
  },

  setDuration: function(newDuration, ephemeral) {
    if (ephemeral && this.opts.cookie.maxAge) {
      throw new Error("you cannot have an ephemeral cookie with a maxAge.");
    }
    if (!this.loaded) {
      this.loadFromCookie(true);
    }
    this.dirty = true;
    this.duration = newDuration;
    this.createdAt = new Date().getTime();
    this.opts.cookie.ephemeral = ephemeral;
    this.updateDefaultExpires();
  },

  // take the content and do the encrypt-and-sign
  // boxing builds in the concept of createdAt
  box: function() {
    return encode(this.opts, this._content, this.duration, this.createdAt);
  },

  unbox: function(content) {
    this.clearContent();

    var unboxed = decode(this.opts, content);
    if (!unboxed) {
      return;
    }

    var self = this;


    Object.keys(unboxed.content).forEach(function(k) {
      self._content[k] = unboxed.content[k];
    });

    this.createdAt = unboxed.createdAt;
    this.duration = unboxed.duration;
    this.updateDefaultExpires();
  },

  updateCookie: function() {
    if (this.isDirty()) {
      // support for adding/removing cookie expires
      this.opts.cookie.expires = this.expires;

      try {
        this.cookies.set(this.opts.cookieName, this.box(), this.opts.cookie);
      } catch (x) {
        // this really shouldn't happen. Right now it happens if secure is set
        // but cookies can't determine that the connection is secure.
      }
    }
  },

  loadFromCookie: function(forceReset) {
    var cookie = this.cookies.get(this.opts.cookieName);
    if (cookie) {
      this.unbox(cookie);

      var expiresAt = this.createdAt + this.duration;
      var now = Date.now();
      // should we reset this session?
      if (expiresAt < now) {
        this.reset();
      // if expiration is soon, push back a few minutes to not interrupt user
      } else if (expiresAt - now < this.activeDuration) {
        this.createdAt += this.activeDuration;
        this.dirty = true;
        this.updateDefaultExpires();
      }
    } else {
      if (forceReset) {
        this.reset();
      } else {
        return false; // didn't actually load the cookie
      }
    }

    this.loaded = true;
    this.json = JSON.stringify(this._content);
    return true;
  },

  isDirty: function() {
    return this.dirty || (this.json !== JSON.stringify(this._content));
  }

};

Object.defineProperty(Session.prototype, 'content', {
  get: function getContent() {
    if (!this.loaded) {
      this.loadFromCookie();
    }
    return this._content;
  },
  set: function setContent(value) {
    Object.defineProperty(value, 'reset', {
      enumerable: false,
      value: this.reset.bind(this)
    });
    Object.defineProperty(value, 'destroy', {
      enumerable: false,
      value: this.destroy.bind(this)
    });
    Object.defineProperty(value, 'setDuration', {
      enumerable: false,
      value: this.setDuration.bind(this)
    });
    this._content = value;
  }
});

function clientSessionFactory(opts) {
  if (!opts) {
    throw new Error("no options provided, some are required");
  }

  if (!(opts.secret || (opts.encryptionKey && opts.signatureKey))) {
    throw new Error("cannot set up sessions without a secret "+
                    "or encryptionKey/signatureKey pair");
  }

  // defaults
  opts.cookieName = opts.cookieName || "session_state";
  opts.duration = opts.duration || 24*60*60*1000;
  opts.activeDuration = 'activeDuration' in opts ?
    opts.activeDuration : ACTIVE_DURATION;

  var encAlg = opts.encryptionAlgorithm || DEFAULT_ENCRYPTION_ALGO;
  encAlg = encAlg.toLowerCase();
  if (!ENCRYPTION_ALGORITHMS[encAlg]) {
    throw new Error('invalid encryptionAlgorithm, supported are: '+
                    Object.keys(ENCRYPTION_ALGORITHMS).join(', '));
  }
  opts.encryptionAlgorithm = encAlg;

  var sigAlg = opts.signatureAlgorithm || DEFAULT_SIGNATURE_ALGO;
  sigAlg = sigAlg.toLowerCase();
  if (!SIGNATURE_ALGORITHMS[sigAlg]) {
    throw new Error('invalid signatureAlgorithm, supported are: '+
                    Object.keys(SIGNATURE_ALGORITHMS).join(', '));
  }
  opts.signatureAlgorithm = sigAlg;

  // set up cookie defaults
  opts.cookie = opts.cookie || {};
  if (typeof opts.cookie.httpOnly === 'undefined') {
    opts.cookie.httpOnly = true;
  }

  // let's not default to secure just yet,
  // as this depends on the socket being secure,
  // which is tricky to determine if proxied.
  /*
  if (typeof(opts.cookie.secure) == 'undefined')
    opts.cookie.secure = true;
    */

  setupKeys(opts);
  keyConstraints(opts);

  const propertyName = opts.requestKey || opts.cookieName;

  return function clientSession(req, res, next) {
    if (propertyName in req) {
      return next(); //self aware
    }

    var cookies = new Cookies(req, res);
    var rawSession;
    try {
      rawSession = new Session(req, res, cookies, opts);
    } catch (x) {
      // this happens only if there's a big problem
      process.nextTick(function() {
        next(new Error("client-sessions error: " + x.toString()));
      });
      return;
    }

    Object.defineProperty(req, propertyName, {
      get: function getSession() {
        return rawSession.content;
      },
      set: function setSession(value) {
        if (isObject(value)) {
          rawSession.content = value;
        } else {
          throw new TypeError("cannot set client-session to non-object");
        }
      }
    });


    var writeHead = res.writeHead;
    res.writeHead = function () {
      rawSession.updateCookie();
      return writeHead.apply(res, arguments);
    };

    next();
  };
}

module.exports = clientSessionFactory;


// Expose encode and decode method

module.exports.util = {
  encode: encode,
  decode: decode,
  computeHmac: computeHmac
};
