plugin-spartacus.js

/**
 * @module kadence/spartacus
 */

'use strict';

const merge = require('merge');
const assert = require('node:assert');
const secp256k1 = require('secp256k1');
const utils = require('./utils');
const jsonrpc = require('jsonrpc-lite');
const { Transform } = require('node:stream');


/**
 * Implements the spartacus decorations to the node object
 */
class SpartacusPlugin {

  static get DEFAULTS() {
    return {
      checkPublicKeyHash: true
    };
  }

  /**
   * Creates the plugin instance given a node and optional identity
   * @constructor
   * @param {KademliaNode} node
   * @param {buffer} [privateKey] - SECP256K1 private key
   * @param {object} [options={}]
   * @param {boolean} [options.checkPublicKeyHash=true]
   */
  constructor(node, priv, opts) {
    priv = priv || utils.generatePrivateKey();

    this.opts = merge(SpartacusPlugin.DEFAULTS, opts);
    this.privateKey = priv;
    this.publicKey = secp256k1.publicKeyCreate(this.privateKey);
    this.identity = utils.toPublicKeyHash(this.publicKey);
    this._validatedContacts = new Map();
    this._pendingValidators = new Map();

    node.contact.pubkey = this.publicKey.toString('hex');
    node.identity = node.router.identity = this.identity;

    node.rpc.serializer.append(() => new Transform({
      transform: this.serialize.bind(this),
      objectMode: true
    }));
    node.rpc.deserializer.prepend(() => new Transform({
      transform: this.deserialize.bind(this),
      objectMode: true
    }));
    node.use((req, res, next) => this.validate(node, req, res, next));
    this.setValidationPeriod();
  }

  /**
   * Sets the validation period for nodes
   * @param {number} period - Milliseconds to honor a proven contact response
   */
  setValidationPeriod(n = 10800000) {
    this._validationPeriod = n;
  }

  /**
   * Checks if the sender is addressable at the claimed contact information
   * and cross checks signatures between the original sender and the node
   * addressed. This is intended to prevent reflection attacks and general
   * DDoS via spam.
   * @param {KademliaNode} node
   * @param {AbstractNode~request} request
   * @param {AbstractNode~response} response
   * @param {AbstractNode~next} next
   */
  validate(node, req, res, next) {
    const period = this._validationPeriod;
    const record = this._validatedContacts.get(req.contact[0]);
    const validated = record && record.validated;
    const fresh = validated && ((Date.now() - record.timestamp) < period);

    if (this._pendingValidators.get(req.contact[0])) {
      return next(); // NB: Let's not get into an infinte validation loop
    }

    if (validated && fresh) {
      return next();
    }

    this._pendingValidators.set(req.contact[0], req.contact[1]);
    node.ping(req.contact, (err) => {
      this._pendingValidators.delete(req.contact[0]);

      if (err) {
        return this._validatedContacts.set(req.contact[0], {
          validated: false,
          timestamp: Date.now()
        });
      }

      this._validatedContacts.set(req.contact[0], {
        validated: true,
        timestamp: Date.now()
      });
      next();
    });
  }

  /**
   * Processes with JsonRpcSerializer then signs the result and appends an
   * additional payload containing signature+identity information
   * @implements {Messenger~serializer}
   */
  serialize(data, encoding, callback) {
    let [id, buffer, target] = data;
    let payload = jsonrpc.parse(buffer.toString('utf8')).map((obj) => {
      return obj.payload;
    });
    let { signature, recovery } = secp256k1.sign(
      utils._sha256(buffer),
      this.privateKey
    );
    let authenticate = jsonrpc.notification('AUTHENTICATE', [
      Buffer.concat([Buffer.from([recovery]), signature]).toString('base64'),
      this.publicKey.toString('hex')
    ]);

    payload.push(authenticate);
    callback(null, [
      id,
      Buffer.from(JSON.stringify(payload), 'utf8'),
      target
    ]);
  }

  /**
   * Parses and verifies the signature payload, then passes through to the
   * JsonRpcDeserializer if successful
   * @implements {Messenger~deserializer}
   */
  deserialize(buffer, encoding, callback) {
    /* eslint max-statements: [2, 30] */
    /* eslint complexity: [2, 12] */
    let payload = jsonrpc.parse(buffer.toString('utf8'))

    try {
      payload = payload.map(obj => {
        assert(obj.type !== 'invalid');
        return obj.payload;
      });
    } catch (err) {
      return callback(new Error('Failed to parse received payload'));
    }

    let [, identify] = payload;
    let authenticate = payload.filter(m => m.method === 'AUTHENTICATE').pop();

    if (typeof authenticate === 'undefined') {
      return callback(new Error('Missing authentication payload in message'));
    }

    let identity = Buffer.from(identify.params[0], 'hex');
    let [signature, publicKey] = authenticate.params;

    let signedPayload = [];

    for (let i = 0; i < payload.length; i++) {
      if (payload[i].method === 'AUTHENTICATE') {
        break;
      } else {
        signedPayload.push(payload[i]);
      }
    }

    signedPayload = utils._sha256(
      Buffer.from(JSON.stringify(signedPayload), 'utf8')
    );

    let publicKeyHash = utils.toPublicKeyHash(Buffer.from(publicKey, 'hex'));
    let pendingValid = this._pendingValidators.get(
      identity.toString('hex')
    );

    if (pendingValid && pendingValid.pubkey !== publicKey) {
      return callback(new Error('Failed pending contact validation'));
    }

    if (this.opts.checkPublicKeyHash && publicKeyHash.compare(identity) !== 0) {
      return callback(new Error('Identity does not match public key'));
    }

    try {
      assert.ok(secp256k1.verify(
        signedPayload,
        Buffer.from(signature, 'base64').slice(1),
        Buffer.from(publicKey, 'hex')
      ));
    } catch (err) {
      return callback(new Error('Message includes invalid signature'));
    }

    callback(null, buffer);
  }

}

/**
 * Registers a {@link module:kadence/spartacus~SpartacusPlugin} with a
 * {@link KademliaNode}
 * @param {string} priv - Private key
 * @param {object} opts - Plugin options
 */
module.exports = function(priv, opts) {
  return function(node) {
    return new SpartacusPlugin(node, priv, opts);
  };
};

module.exports.SpartacusPlugin = SpartacusPlugin;