plugin-hashcash.js

/**
 * @module kadence/hashcash
 */

'use strict';

const { fork } = require('node:child_process');
const path = require('node:path');
const { Transform } = require('node:stream');
const async = require('async');
const merge = require('merge');
const jsonrpc = require('jsonrpc-lite');
const crypto = require('node:crypto');
const assert = require('node:assert');
const LRUCache = require('lru-cache');
const utils = require('./utils');


/**
 * Requires proof of work to process messages and performs said work before
 * issuing RPC messages to peers
 */
class HashCashPlugin {

  static get METHOD() {
    return 'HASHCASH';
  }

  static get DEFAULTS() {
    return {
      methods: [], // All methods by default
      difficulty: 8, // 8 leading zeroes
      timeframe: 172800000 // 2 day window
    };
  }

  /**
   * @constructor
   * @param {object} node
   * @param {object} [options]
   * @param {string[]} [options.methods=[]] - RPC methods to enforce hashcash
   * @param {number} [options.difficulty=8] - Leading zero bits in stamp
   * @param {number} [options.timeframe=172800000] - Timestamp valid window
   */
  constructor(node, options = {}) {
    this._opts = merge(HashCashPlugin.DEFAULTS, options);
    this._node = node;
    this._cache = new LRUCache(1000);

    this._node.rpc.deserializer.prepend(() => new Transform({
      transform: this.verify.bind(this),
      objectMode: true
    }));

    this._node.rpc.serializer.append(() => new Transform({
      transform: this.prove.bind(this),
      objectMode: true
    }));
  }

  /**
   * Verifies the proof of work on the request object
   * @implements {Messenger~deserializer}
   */
  verify(data, encoding, callback) {
    /* eslint max-statements: [2, 26] */
    let payload = jsonrpc.parse(data.toString('utf8')).map((obj) => {
      return obj.payload;
    });
    let verifyMessage = (this._opts.methods.includes(payload[0].method) ||
      this._opts.methods.length === 0) &&
      typeof payload[0].method !== 'undefined';

    if (!verifyMessage) {
      return callback(null, data);
    }

    let proof = payload.filter(m => m.method === HashCashPlugin.METHOD).pop();
    let contact = payload.filter(m => m.method === 'IDENTIFY').pop();

    if (!proof) {
      return callback(new Error('HashCash stamp is missing from payload'));
    }

    let stamp = HashCashPlugin.parse(proof.params[0]);
    let sender = stamp.resource.substr(0, 40);
    let target = Buffer.from(stamp.resource.substr(40, 40), 'hex');
    let method = Buffer.from(
      stamp.resource.substr(80),
      'hex'
    ).toString('utf8');

    try {
      assert(this._cache.get(stamp.toString()) !== 1, 'Cannot reuse proof');
      assert(stamp.bits === this._opts.difficulty, 'Invalid proof difficulty');
      assert(sender === contact.params[0], 'Invalid sender in proof');
      assert(
        Buffer.compare(target, this._node.identity) === 0,
        'Invalid target in proof'
      );
      assert(method === payload[0].method, 'Invalid proof for called method');

      let now = Date.now();

      assert(utils.satisfiesDifficulty(utils.hash160(stamp.toString()),
        this._opts.difficulty), 'Invalid HashCash stamp');
      assert(
        now - Math.abs(stamp.date) <= this._opts.timeframe,
        'HashCash stamp is expired'
      );
    } catch (err) {
      return callback(err);
    }

    this._cache.set(stamp.toString(), 1);
    callback(null, data);
  }

  /**
   * Add proof of work to outgoing message
   * @implements {Messenger~serializer}
   */
  prove(data, encoding, callback) {
    let [id, buffer, target] = data;
    let now = Date.now();
    let payload = jsonrpc.parse(buffer.toString('utf8')).map((obj) => {
      return obj.payload;
    });
    let stampMessage = (this._opts.methods.includes(payload[0].method) ||
      this._opts.methods.length === 0) &&
      typeof payload[0].method !== 'undefined';

    if (!stampMessage) {
      return callback(null, data);
    }

    this._node.logger.debug(`mining hashcash stamp for ${payload[0].method}`);

    // NB: "Pause" the timeout timer for the request this is associated with
    // NB: so that out mining does not eat into the reasonable time for a
    // NB: response.
    const pending = this._node._pending.get(id) || {};
    pending.timestamp = Infinity;

    HashCashPlugin.create(
      this._node.identity.toString('hex'),
      target[0],
      payload[0].method,
      this._opts.difficulty,
      (err, result) => {
        if (err) {
          return callback(err);
        }

        pending.timestamp = Date.now(); // NB: Reset the timeout counter

        let delta = Date.now() - now;
        let proof = jsonrpc.notification(HashCashPlugin.METHOD, [
          result.header
        ]);

        this._node.logger.debug(`mined stamp ${result.header} in ${delta}ms`);
        payload.push(proof);
        callback(null, [
          id,
          Buffer.from(JSON.stringify(payload), 'utf8'),
          target
        ]);
      }
    );
  }

  /**
   * Parses hashcash stamp header into an object
   * @static
   * @param {string} header - Hashcash header proof stamp
   * @returns {module:kadence/hashcash~HashCashPlugin~stamp}
   */
  static parse(header) {
    let parts = header.split(':');
    let parsed = {
      ver: parseInt(parts[0]),
      bits: parseInt(parts[1]),
      date: parseInt(parts[2]),
      resource: parts[3],
      ext: '',
      rand: parts[5],
      counter: parseInt(parts[6], 16),
      toString() {
        return [
          this.ver, this.bits, this.date, this.resource,
          this.ext, this.rand, this.counter.toString(16)
        ].join(':');
      }
    };

    return parsed;
  }
  /**
   * @typedef module:kadence/hashcash~HashCashPlugin~stamp
   * @property {number} ver - Hashcash version
   * @property {number} bits - Number of zero bits of difficulty
   * @property {number} date - UNIX timestamp
   * @property {string} resource - Sender and target node identities
   * @property {string} ext - Empty string
   * @property {string} rand - String encoded random number
   * @property {number} counter - Base 16 counter
   * @property {function} toString - Reserializes the parsed header
   */

  /**
   * Creates the hashcash stamp header
   * @static
   * @param {string} sender
   * @param {string} target
   * @param {string} method
   * @param {number} difficulty
   * @param {function} callback
   */
  /* eslint max-params: [2, 5] */
  static create(sender = '00', target = '00', method = '00', bits = 8, cb) {
    const proc = fork(
      path.join(__dirname, 'plugin-hashcash.worker.js'),
      [
        sender,
        target,
        method,
        bits
      ],
      {
        env: process.env
      }
    );

    proc.on('message', msg => {
      if (msg.error) {
        return cb(new Error(msg.error));
      }

      cb(null, msg);
    });
  }

  /**
   * @private
   */
  static _worker(sender = '00', target = '00', method = '00', bits = 8, cb) {
    let header = {
      ver: 1,
      bits: bits,
      date: Date.now(),
      resource: Buffer.concat([
        Buffer.from(sender, 'hex'),
        Buffer.from(target, 'hex'),
        Buffer.from(method)
      ]).toString('hex'),
      ext: '',
      rand: crypto.randomBytes(12).toString('base64'),
      counter: Math.ceil(Math.random() * 10000000000),
      toString() {
        return [
          this.ver, this.bits, this.date, this.resource,
          this.ext, this.rand, this.counter.toString(16)
        ].join(':');
      }
    };

    function isSolution() {
      return utils.satisfiesDifficulty(utils.hash160(header.toString()), bits);
    }

    async.whilst(() => !isSolution(), (done) => {
      setImmediate(() => {
        header.counter++;
        done();
      });
    }, () => {
      cb(null, {
        header: header.toString(),
        time: Date.now() - header.date
      });
    });
  }

}

/**
 * Registers the {@link module:kadence/hashcash~HashCashPlugin} with an
 * {@link AbstractNode}
 * @param {object} [options]
 * @param {string[]} [options.methods=[]] - RPC methods to enforce hashcash
 * @param {number} [options.difficulty=8] - Leading zero bits in stamp
 * @param {number} [options.timeframe=172800000] - Timestamp valid window
 */
module.exports = function(options) {
  return function(node) {
    return new HashCashPlugin(node, options);
  }
};

module.exports.HashCashPlugin = HashCashPlugin;