/**
* @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;