utils.js

/**
* @module kadence/utils
*/

'use strict';

const secp256k1 = require('secp256k1');
const url = require('node:url');
const constants = require('./constants');
const semver = require('semver');
const ip = require('ip');
const crypto = require('node:crypto');
const assert = require('node:assert');
const { randomBytes, createHash } = crypto;
const ms = require('ms');
const equihash = require('@tacticalchihuahua/equihash');
const onionRegex = require('onion-regex');


/**
 * Tests if a string is valid hex
 * @param {string} str
 * @returns {boolean}
 */
module.exports.isHexaString = function(str) {
  return Buffer.from(str, 'hex').length === str.length / 2;
};

/**
 * Returns a random valid key/identity as a string
 * @returns {string}
 */
exports.getRandomKeyString = function() {
  return exports.getRandomKeyBuffer().toString('hex');
};

/**
 * Returns a random valid key/identity as a buffer
 * @returns {buffer}
 */
exports.getRandomKeyBuffer = function() {
  return crypto.randomBytes(constants.B / 8);
};

/**
 * Determines if the given string key is valid
 * @param {string} key - Node ID or item key
 * @returns {boolean}
 */
exports.keyStringIsValid = function(key) {
  let buf;

  try {
    buf = Buffer.from(key, 'hex');
  } catch (err) {
    return false;
  }

  return exports.keyBufferIsValid(buf);
};

/**
 * Determines if the given buffer key is valid
 * @param {buffer} key - Node ID or item key
 * @returns {boolean}
 */
exports.keyBufferIsValid = function(key) {
  return Buffer.isBuffer(key) && key.length === constants.B / 8;
};

/**
 * Calculate the distance between two keys
 * @param {string} key1 - Identity key to compare
 * @param {string} key2 - Identity key to compare
 * @returns {buffer}
 */
exports.getDistance = function(id1, id2) {
  id1 = !Buffer.isBuffer(id1)
    ? Buffer.from(id1, 'hex')
    : id1;
  id2 = !Buffer.isBuffer(id2)
    ? Buffer.from(id2, 'hex')
    : id2;

  assert(exports.keyBufferIsValid(id1), 'Invalid key supplied');
  assert(exports.keyBufferIsValid(id2), 'Invalid key supplied');

  return Buffer.alloc(constants.B / 8)
    .map((b, index) => id1[index] ^ id2[index]);
};

/**
 * Compare two buffers for sorting
 * @param {buffer} b1 - Buffer to compare
 * @param {buffer} b2 - Buffer to compare
 * @returns {number}
 */
exports.compareKeyBuffers = function(b1, b2) {
  assert(exports.keyBufferIsValid(b1), 'Invalid key supplied');
  assert(exports.keyBufferIsValid(b2), 'Invalid key supplied');

  for (let index = 0; index < b1.length; index++) {
    let bits = b1[index];

    if (bits !== b2[index]) {
      return bits < b2[index] ? -1 : 1;
    }
  }

  return 0;
};

/**
 * Calculate the index of the bucket that key would belong to
 * @param {string} referenceKey - Key to compare
 * @param {string} foreignKey - Key to compare
 * @returns {number}
 */
exports.getBucketIndex = function(referenceKey, foreignKey) {
  let distance = exports.getDistance(referenceKey, foreignKey);
  let bucketIndex = constants.B;

  for (let byteValue of distance) {
    if (byteValue === 0) {
      bucketIndex -= 8;
      continue;
    }

    for (let i = 0; i < 8; i++) {
      if (byteValue & (0x80 >> i)) {
        return --bucketIndex;
      } else {
        bucketIndex--;
      }
    }
  }

  return bucketIndex;
};

/**
 * Returns a buffer with a power-of-two value given a bucket index
 * @param {string|buffer} referenceKey - Key to find next power of two
 * @param {number} bucketIndex - Bucket index for key
 * @returns {buffer}
 */
exports.getPowerOfTwoBufferForIndex = function(referenceKey, exp) {
  assert(exp >= 0 && exp < constants.B, 'Index out of range');

  const buffer = Buffer.isBuffer(referenceKey)
    ? Buffer.from(referenceKey)
    : Buffer.from(referenceKey, 'hex');
  const byteValue = parseInt(exp / 8);

  // NB: We set the byte containing the bit to the right left shifted amount
  buffer[constants.K - byteValue - 1] = 1 << (exp % 8);

  return buffer;
};

/**
 * Generate a random number within the bucket's range
 * @param {buffer} referenceKey - Key for bucket distance reference
 * @param {number} index - Bucket index for random buffer selection
 */
exports.getRandomBufferInBucketRange = function(referenceKey, index) {
  let base = exports.getPowerOfTwoBufferForIndex(referenceKey, index);
  let byte = parseInt(index / 8); // NB: Randomize bytes below the power of two

  for (let i = constants.K - 1; i > (constants.K - byte - 1); i--) {
    base[i] = parseInt(Math.random() * 256);
  }

  // NB: Also randomize the bits below the number in that byte and remember
  // NB: arrays are off by 1
  for (let j = index - 1; j >= byte * 8; j--) {
    let one = Math.random() >= 0.5;
    let shiftAmount = j - byte * 8;

    base[constants.K - byte - 1] |= one ? (1 << shiftAmount) : 0;
  }

  return base;
};

/**
 * Validates the given object is a storage adapter
 * @param {AbstractNode~storage} storageAdapter
 */
exports.validateStorageAdapter = function(storage) {
  assert(typeof storage === 'object',
    'No storage adapter supplied');
  assert(typeof storage.get === 'function',
    'Store has no get method');
  assert(typeof storage.put === 'function',
    'Store has no put method');
  assert(typeof storage.del === 'function',
    'Store has no del method');
  assert(typeof storage.createReadStream === 'function',
    'Store has no createReadStream method');
};

/**
 * Validates the given object is a logger
 * @param {AbstractNode~logger} logger
 */
exports.validateLogger = function(logger) {
  assert(typeof logger === 'object',
    'No logger object supplied');
  assert(typeof logger.debug === 'function',
    'Logger has no debug method');
  assert(typeof logger.info === 'function',
    'Logger has no info method');
  assert(typeof logger.warn === 'function',
    'Logger has no warn method');
  assert(typeof logger.error === 'function',
    'Logger has no error method');
};

/**
 * Validates the given object is a transport
 * @param {AbstractNode~transport} transport
 */
exports.validateTransport = function(transport) {
  assert(typeof transport === 'object',
    'No transport adapter supplied');
  assert(typeof transport.read === 'function',
    'Transport has no read method');
  assert(typeof transport.write === 'function',
    'Transport has no write method');
};

/**
 * Returns the SHA-256 hash of the input
 * @param {buffer} input - Data to hash
 */
module.exports.hash256 = function(input) {
  return crypto.createHash('sha256').update(input).digest();
};

/**
 * @typedef EquihashProof
 * @type {object}
 * @property {number} n
 * @property {number} k
 * @property {number} nonce
 * @property {buffer} value
 */

/**
 * Performs an equihash solution using defaults
 * @param {buffer} input - Input hash to solve
 * @returns {Promise<EquihashProof>}
 */
module.exports.eqsolve = equihash.solve;

/**
 * Perform an equihash proof verification
 * @param {buffer} input - Input hash for proof
 * @param {buffer} proof - Equihash proof to verify
 * @returns {boolean}
 */
module.exports.eqverify = equihash.verify;

/**
 * Returns the RMD-160 hash of the input
 * @param {buffer} input - Data to hash
 */
module.exports.hash160 = function(input) {
  return crypto.createHash('ripemd160').update(input).digest();
};

/**
 * Returns a stringified URL from the supplied contact object
 * @param {Bucket~contact} contact
 * @returns {string}
 */
module.exports.getContactURL = function(contact) {
  const [id, info] = contact;

  return `${info.protocol}//${info.hostname}:${info.port}/#${id}`;
};

/**
 * Returns a parsed contact object from a URL
 * @returns {object}
 */
module.exports.parseContactURL = function(addr) {
  const { protocol, hostname, port, hash } = url.parse(addr);
  const contact = [
    (hash ? hash.substr(1) : null) ||
      Buffer.alloc(constants.B / 8).fill(0).toString('hex'),
    {
      protocol,
      hostname,
      port
    }
  ];

  return contact;
};

/**
 * Returns whether or not the supplied semver tag is compatible
 * @param {string} version - The semver tag from the contact
 * @returns {boolean}
 */
module.exports.isCompatibleVersion = function(version) {
  const local = require('./version').protocol;
  const remote = version;
  const sameMajor = semver.major(local) === semver.major(remote);
  const diffs = ['prerelease', 'prepatch', 'preminor', 'premajor'];

  if (diffs.indexOf(semver.diff(remote, local)) !== -1) {
    return false;
  } else {
    return sameMajor;
  }
};

/**
 * Determines if the supplied contact is valid
 * @param {Bucket~contact} contact - The contact information for a given peer
 * @param {boolean} loopback - Allows contacts that are localhost
 * @returns {boolean}
 */
module.exports.isValidContact = function(contact, loopback) {
  const [, info] = contact;
  const isValidAddr = ip.isV4Format(info.hostname) ||
                      ip.isV6Format(info.hostname) ||
                      ip.isPublic(info.hostname) ||
                      onionRegex.v3({ exact: true }).test(info.hostname);
  const isValidPort = info.port > 0;
  const isAllowedAddr = ip.isLoopback(info.hostname) ? !!loopback : true;

  return isValidPort && isValidAddr && isAllowedAddr;
};

/**
 * Converts a buffer to a string representation of binary
 * @param {buffer} buffer - Byte array to convert to binary string
 * @returns {string}
 */
module.exports.toBinaryStringFromBuffer = function(buffer) {
  const mapping = {
    '0': '0000',
    '1': '0001',
    '2': '0010',
    '3': '0011',
    '4': '0100',
    '5': '0101',
    '6': '0110',
    '7': '0111',
    '8': '1000',
    '9': '1001',
    'a': '1010',
    'b': '1011',
    'c': '1100',
    'd': '1101',
    'e': '1110',
    'f': '1111'
  };
  const hexaString = buffer.toString('hex').toLowerCase();
  const bitmaps = [];

  for (let i = 0; i < hexaString.length; i++) {
    bitmaps.push(mapping[hexaString[i]]);
  }

  return bitmaps.join('');
};

/**
 * Returns a boolean indicating if the supplied buffer meets the given
 * difficulty requirement
 * @param {buffer} buffer - Buffer to check difficulty
 * @param {number} difficulty - Number of leading zeroes
 * @returns {boolean}
 */
module.exports.satisfiesDifficulty = function(buffer, difficulty) {
  const binString = module.exports.toBinaryStringFromBuffer(buffer);
  const prefix = Array(difficulty).fill('0').join('');

  return binString.substr(0, difficulty) === prefix;
};

/**
 * @private
 */
module.exports._sha256 = function(input) {
  return createHash('sha256').update(input).digest();
};

/**
 * @private
 */
module.exports._rmd160 = function(input) {
  return createHash('ripemd160').update(input).digest();
};

/**
 * Generates a private key
 * @returns {buffer}
 */
module.exports.generatePrivateKey = function() {
  let privKey

  do {
    privKey = randomBytes(32);
  } while (!secp256k1.privateKeyVerify(privKey))

  return privKey;
};

/**
 * Takes a public key are returns the identity
 * @param {buffer} publicKey - Raw public key bytes
 * @returns {buffer}
 */
module.exports.toPublicKeyHash = function(publicKey) {
  return exports._rmd160(exports._sha256(publicKey));
};

/**
 * Wraps the supplied function in a pseudo-random length timeout to help
 * prevent convoy effects. These occur when a number of processes need to use
 * a resource in turn. There is a tendency for such bursts of activity to
 * drift towards synchronization, which can be disasterous. In Kademlia all
 * nodes are requird to republish their contents every hour (T_REPLICATE). A
 * convoy effect might lead to this being synchronized across the network,
 * which would appear to users as the network dying every hour. The default
 * timeout will be between 0 and 30 minutes unless specified.
 * @param {function} func - Function to wrap to execution later
 * @param {number} [maxtime] - Maximum timeout
 * @returns {function}
 */
module.exports.preventConvoy = function(func, timeout) {
  return function() {
    let t = Math.ceil(
      Math.random() * (typeof timeout !== 'number' ? ms('30m') : timeout)
    );
    return setTimeout(func, t);
  };
};