utils/key-utils.js

/**
 * @fileOverview Utility encryption related functions for KeyManager module.
 *
 * @author James Aman (j.aman@topl.me)
 * @author Raul Aragonez (r.aragonez@topl.me)
 *
 * @exports KeyUtils create, dump, recover, str2buf, generateKeystoreFilename
 */

"use strict";

// Dependencies
const blake = require("blake2");
const crypto = require("crypto");
const Base58 = require("bs58");
const curve25519 = require("curve25519-js");

const utils = require("../utils/address-utils.js");

/* ------------------------------ Generic key utils  ------------------------------ */

/**
 * Convert a string to a Buffer with optional Node builtin encoding specified.
 * If encoding is not specified, Base58 encoding will be assumed, if the input is valid.
 * @param {string} str String to be converted.
 * @param {string=} enc Encoding of the input string (optional).
 * @returns {Buffer} Buffer (bytearray) containing the input data.
 */
function str2buf(str, enc) {
  if (!str || str.constructor !== String) return str;
  else if (enc === "base58") return Buffer.from(Base58.decode(str));
  else return enc ? Buffer.from(str, enc) : Buffer.from(Base58.decode(str));
}

/**
 * Check if the selected cipher is available.
 * @param {string} cipher Encryption algorithm.
 * @returns {boolean} If available true, otherwise false.
 */
function isCipherAvailable(cipher) {
  return crypto.getCiphers().some(function(name) {
    return name === cipher;
  });
}

/**
 * Symmetric privateKey + secretKey encryption using secret (derived) key.
 * @param {Buffer|string} plaintext Data to be encrypted.
 * @param {Buffer|string} key Secret key.
 * @param {Buffer|string} iv Initialization vector.
 * @param {string=} algo Encryption algorithm (default: constants.cipher).
 * @returns {Buffer} Encrypted data.
 */
function encrypt(plaintext, key, iv, algo) {
  if (!isCipherAvailable(algo)) throw new Error(algo + " is not available");
  const cipher = crypto.createCipheriv(algo, str2buf(key), str2buf(iv));
  const ciphertext = cipher.update(str2buf(plaintext));

  return Buffer.concat([ciphertext, cipher.final()]);
}

/**
 * Symmetric privateKey + secretKey decryption using secret (derived) key.
 * @param {Buffer|string} ciphertext Data to be decrypted.
 * @param {Buffer|string} key derived key.
 * @param {Buffer|string} iv Initialization vector.
 * @param {string=} algo Encryption algorithm (default: constants.cipher).
 * @returns {Buffer} Decrypted data.
 */
function decrypt(ciphertext, key, iv, algo) {
  if (!isCipherAvailable(algo)) throw new Error(algo + " is not available");
  const decipher = crypto.createDecipheriv(algo, str2buf(key), str2buf(iv));
  const plaintext = decipher.update(str2buf(ciphertext));
  return Buffer.concat([plaintext, decipher.final()]);
}

/**
 * Calculate message authentication code from secret (derived) key and
 * encrypted text. The MAC is the keccak-256 hash of the byte array
 * formed by concatenating the second 16 bytes of the derived key with
 * the ciphertext key's contents.
 * @param {Buffer|string} derivedKey Secret key derived from password.
 * @param {Buffer|string} ciphertext Text encrypted with secret key.
 * @returns {string} Base58-encoded MAC.
 */
function getMAC(derivedKey, ciphertext) {
  const blake2b = (msg) => blake.createHash("blake2b", {digestLength: 32}).update(msg).digest();
  if (derivedKey !== undefined && derivedKey !== null && ciphertext !== undefined && ciphertext !== null) {
    return blake2b(Buffer.concat([
      str2buf(derivedKey).slice(16, 32),
      str2buf(ciphertext)
    ]));
  }
}

/**
 * Generate random numbers for private key, initialization vector,
 * and salt (for key derivation).
 * @param {Object} params Encryption options.
 * @param {string} params.keyBytes Private key size in bytes.
 * @param {string} params.ivBytes Initialization vector size in bytes.
 * @returns {Object} Keys, IV and salt.
 */
function create(params) {
  const keyBytes = params.keyBytes;
  const ivBytes = params.ivBytes;

  /**
   * Create hash using Blake2b
   * @param {Object} Buffer buffer to process
   * @returns {Object} has created by blake2b
   */
  function bifrostBlake2b(Buffer) {
    return blake.createHash("blake2b", {digestLength: 32}).update(Buffer).digest();
  }

  /**
   * Generate curve25519 Key
   * @param {Object} randomBytes random bytes
   * @returns {Object} curve25519 Key as obj
   */
  function curve25519KeyGen(randomBytes) {
    const {public: pk, private: sk} = curve25519.generateKeyPair(bifrostBlake2b(randomBytes));
    return {
      publicKey: Buffer.from(pk),
      privateKey: Buffer.from(sk),
      iv: bifrostBlake2b(crypto.randomBytes(keyBytes + ivBytes + keyBytes)).slice(0, ivBytes),
      salt: bifrostBlake2b(crypto.randomBytes(keyBytes + ivBytes))
    };
  }

  return curve25519KeyGen(crypto.randomBytes(keyBytes + ivBytes + keyBytes));
}

/**
 * Derive secret key from password with key derivation function.
 * @param {String|Buffer} password User-supplied password.
 * @param {String|Buffer} salt Randomly generated salt.
 * @param {Object} [kdfParams] key-derivation parameters
 * @returns {Buffer} Secret key derived from password.
 */
function deriveKey(password, salt, kdfParams) {
  if (typeof password === "undefined" || password === null || !salt) {
    throw new Error("Must provide password and salt to derive a key");
  }

  // convert strings to Buffers
  password = str2buf(password, "latin1");
  salt = str2buf(salt);

  // get scrypt parameters
  const dkLen = kdfParams.dkLen;
  const N = kdfParams.n;
  const r = kdfParams.r;
  const p = kdfParams.p;
  const maxmem = 2 * 128 * N * r;

  return crypto.scryptSync(password, salt, dkLen, {N, r, p, maxmem});
}

/**
 * Assemble key data object in secret-storage format.
 * @param {Buffer} derivedKey Password-derived secret key.
 * @param {Object} keyObject Object containing the raw public / private keypair
 * @param {Buffer} salt Randomly generated salt.
 * @param {Buffer} iv Initialization vector.
 * @param {Buffer} algo encryption algorithm to be used
 * @param {String} network network prefix as string i.e local/private/toplnet
 * @returns {Object} key data object in secret-storage format
 */
function marshal(derivedKey, keyObject, salt, iv, algo, network) {
  // for cipherText: encryption of public + private key
  const concatKeys = Buffer.concat([keyObject.privateKey, keyObject.publicKey], 64);

  // encrypt using last 16 bytes of derived key (this matches Bifrost)
  const ciphertext = encrypt(concatKeys, derivedKey, iv, algo);

  // generate address
  const createAddress = utils.generatePubKeyHashAddress(keyObject.publicKey, network);
  if (createAddress && !createAddress.success) {
    throw new Error(createAddress.errorMsg);
  }

  const keyStorage = {
    address: createAddress.address,
    crypto: {
      mac: Base58.encode(getMAC(derivedKey, ciphertext)),
      kdf: "scrypt",
      cipherText: Base58.encode(ciphertext),
      kdfSalt: Base58.encode(salt),
      cipher: algo,
      cipherParams: {iv: Base58.encode(iv)}
    }
  };

  return keyStorage;
}

/**
 * Export private key to keystore secret-storage format.
 * @param {string|Buffer} password User-supplied password.
 * @param {Object} keyObject Object containing the raw public / private keypair
 * @param {Buffer} options encryption algorithm to be used
 * @returns {Object} keyStorage for use with exportToFile
 */
function dump(password, keyObject, options) {
  const kdfParams = options.kdfParams || options.scrypt;
  const iv = str2buf(keyObject.iv);
  const salt = str2buf(keyObject.salt);
  const privateKey = str2buf(keyObject.privateKey);
  const publicKey = str2buf(keyObject.publicKey);

  return marshal(deriveKey(password, salt, kdfParams), {privateKey, publicKey}, salt, iv, options.cipher, options.networkPrefix);
}

/**
 * Recover plaintext private key from secret-storage key object.
 * @param {string|Buffer} password User-supplied password.
 * @param {Object} keyStorage Keystore object.
 * @param {Object} [kdfParams] key-derivation parameters
 * @returns {Buffer} Plaintext private key.
 */
function recover(password, keyStorage, kdfParams) {
  /**
   * Verify that message authentication codes match, then decrypt
   * @param {Buffer} derivedKey Password-derived secret key.
   * @param {Buffer} iv Initialization vector.
   * @param {Object} ciphertext cipher text
   * @param {Object} mac keccak-256 hash of the byte array
   * @param {Buffer} algo encryption algorithm to be used
   * @returns {object} returns result of fn decrypt
   */
  function verifyAndDecrypt(derivedKey, iv, ciphertext, mac, algo) {
    if (!getMAC(derivedKey, ciphertext).equals(mac)) {
      throw new Error("message authentication code mismatch");
    }
    return decrypt(ciphertext, derivedKey, iv, algo);
  }

  const iv = str2buf(keyStorage.crypto.cipherParams.iv);
  const salt = str2buf(keyStorage.crypto.kdfSalt);
  const ciphertext = str2buf(keyStorage.crypto.cipherText);
  const mac = str2buf(keyStorage.crypto.mac);
  const algo = keyStorage.crypto.cipher;

  return keysEncodedFormat(verifyAndDecrypt(deriveKey(password, salt, kdfParams), iv, ciphertext, mac, algo));
}

/**
 * Parse KeysBuffer and split into [secretKey, publicKey]
 * @param {Buffer} keysBuffer Buffer containing both keys
 * @returns {Array} Array with format [sk, pk]
 */
function keysEncodedFormat(keysBuffer) {
  if (keysBuffer.length !== 64) {
    throw new Error("Invalid keysBuffer.");
  }
  return [Base58.encode(keysBuffer.slice(0, 32)), Base58.encode(keysBuffer.slice(32))];
}

/**
 * Generate filename for a keystore file.
 * @param {String} publicKey Topl address.
 * @returns {string} Keystore filename.
 */
function generateKeystoreFilename(publicKey) {
  if (typeof publicKey !== "string") throw new Error("PublicKey must be given as a string for the filename");
  const filename = new Date().toISOString() + "-" + publicKey + ".json";

  return filename.split(":").join("-");
}

module.exports = {create, dump, recover, str2buf, generateKeystoreFilename};