index.js

/**
 * Rise is a protocol for decentalized, eclipse-resistant identities. 
 * @module rise
 * @author yipsec <161chihuahuas@disroot.org>
 * @license LGPL-2.1
 */
'use strict';

const crypto = require('node:crypto');
const bip39 = require('bip39');
const ecies = require('eciesjs');
const { secp256k1: secp } = require('@noble/curves/secp256k1');
const equihash = require('@yipsec/equihash');


function _hash(alg, input) {
  return crypto.createHash(alg).update(input).digest();
}

function sha256(input) {
  return _hash('sha256', input);
}

function rmd160(input) {
  return _hash('rmd160', input);
}


class RiseIdentity {

  /**
   * Default difficuly setting. Require this many leading zeroes in solution 
   * proofs.
   */ 
  static get Z() {
    return 6;
  }

  /**
   * Lowered difficulty for testing.
   */
  static get TEST_Z() {
    return 0;
  }

  /**
   * Equihash N parameter (width in bits).
   */
  static get N() {
    return 102;
  }

  /**
   * Lowered width for testing.
   */
  static get TEST_N() {
    return 90;
  }

  /**
   * Equihash K parameter (length).
   */
  static get K() {
    return 5;
  }

  /**
   * Lowered length for testing.
   */
  static get TEST_K() {
    return 5;
  }

  /**
   * Rise magic number. Used as message terminator and protocol identitifier.
   */
  static get MAGIC() {
    return sha256(Buffer.from('¬«', 'binary'));
  }

  /**
   * Magic number to segment test network.
   */
  static get TEST_MAGIC() {
    return sha256(Buffer.from('¡', 'binary'));
  }

  /**
   * Rise default salt for pbkdf2 operations.
   */
  static get SALT() {
    return Buffer.from('\fæ×"\x94ì9\x82BW(i<ªþ`', 'binary');
  }

  /**
   * Rise *private* identity bundle. This is the primary interface for using 
   * this module. Allows to generate new identities and use them as the 
   * context for protected operations.
   * @constructor
   * @param {Uint8Array|buffer} [entropy] - Private key (secp256k1). If absent
   * a new one will be created.
   * @param {RiseSolution} [solution] - Equihash solution corresponding to the 
   * given private key.
   * @param {Uint8Array|buffer} [salt=module:rise~RiseSolution~SALT] - Salt used for local 
   * pbkdf2 operations locking/unlocking this identity.
   */
  constructor(entropy, solution, salt = RiseIdentity.SALT) {
    entropy = entropy || secp.utils.randomPrivateKey();

    /** 
     * Used for local PBKDF2.
     * @member {Uint8Array|buffer}
     */ 
    this.salt = salt; 
    /** 
     * BIP39 recovery words. 
     * @member {string}
     */ 
    this.mnemonic = bip39.entropyToMnemonic(entropy);
    /** 
     * Underlying secret key.
     * @member {module:rise~RiseSecret} 
     */ 
    this.secret = new RiseSecret(entropy);
    /** 
     * Underlying equihash solution.
     * @member {module:rise~RiseSolution}
     */ 
    this.solution = solution || {};
  }

  /**
   * 160 bit solution hash.
   * @member {buffer}
   */ 
  get fingerprint() {
    return this.solution.fingerprint;
  }

  /**
   * Creates a new {@link RiseSolution} for this identity. This method updates 
   * the internal state and will overwrite any previous solution performed in 
   * this context.
   * @param {number} [n=module:rise~RiseIdentity~N] - Width in bits.
   * @param {number} [k=module:rise~RiseIdentity~K] - Solution length.
   * @param {buffer} [epoch=module:rise~RiseIdentity~MAGIC] - Prepended to public key before 
   * hashing. This can be used to segment protocol versions by changing this value
   * which would render solutions generated with a previous or otherwise different 
   * value invalid and require a new solution.
   * @returns {Promise<module:rise~RiseSolution>}
   */ 
  solve(n = RiseIdentity.N, k = RiseIdentity.K, epoch = RiseIdentity.MAGIC) {
    return new Promise((resolve, reject) => {
      equihash.solve(sha256(
        Buffer.concat([epoch, this.secret.publicKey])
      ), n, k).then(solution => {
        this.solution = new RiseSolution(solution.proof, solution.nonce, 
          this.secret.publicKey, epoch);
        resolve(this.solution);
      }, reject);
    });
  }

  /**
   * Returns a plain object representation of this identity, serializable to JSON.
   * @returns {object}
   */
  toJSON() {
    return {
      secret: Buffer.from(this.secret.privateKey).toString('base64'),
      salt: this.salt.toString('base64'),
      version: require('./package.json').version,
      solution: this.solution
        ? this.solution.toJSON()
        : {}
    };
  }

  /**
   * Creates an encrypted blob representation of this identity, suitable for 
   * persistance to disk.
   * @param {string} password - User provided passphrase used to encrypt this 
   * identity.
   * @returns {buffer}
   */ 
  lock(password) {
    const key = crypto.pbkdf2Sync(password, this.salt, 100000, 32, 'sha512');
    const iv = key.subarray(0, 16);
    const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
    const encryptedData = Buffer.concat([
      cipher.update(JSON.stringify(this.toJSON())), 
      cipher.final()
    ]);
    return encryptedData;
  }

  /**
   * Constructs an encrypted and signed {@link module:rise~RiseMessage} for the given 
   * public key.
   * @param {Uint8Array|buffer} toPublicKey - Recipient identity for encryption.
   * @param {Object.<string, string>} [body] - Key-value pairs to serialize in the 
   * message.
   * @param {Object.<string, string>} [head] - Custom headers to include. **Headers 
   * are NOT ENCRYPTED**. Only information that is necessary for routing should be 
   * included here. 
   * @returns {module:rise~SignedRiseMessage}
   */ 
  message(toPublicKey, body = {}, head = {}) {
    const clearMsg = new RiseMessage(this.solution, body, head);
    const cryptMsg = clearMsg.encrypt(toPublicKey);
    return cryptMsg.sign(this.secret.privateKey);
  }

  /**
   * Decrypts the blob given the password and creates a new instance.
   * @param {string} password - User supplied passphrase for decryption.
   * @param {buffer} data - Binary blob of encrypted identity.
   * @param {buffer} [salt=module:rise~RiseIdentity~SALT] - Salt for pbkdf2.
   * @returns {modulex:rise~RiseIdentity}
   */ 
  static unlock(password, data, salt = RiseIdentity.SALT) {
    const key = crypto.pbkdf2Sync(password, salt, 100000, 32, 'sha512');
    const iv = key.subarray(0, 16);
    const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
    const decryptedData = Buffer.concat([
      decipher.update(data), 
      decipher.final()
    ]);
    const json = JSON.parse(decryptedData.toString());

    return new RiseIdentity(
      Buffer.from(json.secret, 'base64'),
      RiseSolution.fromJSON(json.solution),
      Buffer.from(json.salt, 'base64')
    );
  }

  /**
   * "Mines" a new {@link module:rise~RiseIdentity} and *iteratively* generates 
   * {@link module:rise~RiseSolution}s until one is found that satisfies the stated 
   * difficulty.
   * @param {number} [zeroes=module:rise~RiseIdentity~Z] - Difficulty level expressed in 
   * number of leading zero bits.
   * @param {number} [n=module:rise~RiseIdentity~N] - Width in bits.
   * @param {number} [k=module:rise~RiseIdentity~K] - Solution length.
   * @param {buffer} [epoch=module:rise~RiseIdentity~MAGIC] - Network magic number.
   * @returns {module:rise~RiseIdentity}
   */
  static generate(zeroes = RiseIdentity.Z, n, k, epoch) {
    return new Promise(async (resolve, reject) => {
      let id, sol;

      do {
        id = new RiseIdentity();
        try {
          sol = await id.solve(n, k, epoch);
        } catch (e) {
          return reject(e);
        }
      } while (sol.difficulty < zeroes)

      resolve(id);
    });
  }

}


class RiseSecret {

  /**
   * Interface for secp256k1 key pair. If no secret is provided, one will be 
   * generated.
   * @constructor
   * @param {Uint8Array|buffer} [secret] - Private key to use.
   */
  constructor(secret) {
    /** 
     * Underlying private key.
     * @member {Uint8Array} 
     */ 
    this.privateKey = secret
      ? Uint8Array.from(secret)
      : secp.utils.randomPrivateKey();
  }

  /** 
   * Public key derived from private key.
   * @member {Uint8Array}
   */
  get publicKey() {
    return secp.getPublicKey(this.privateKey);
  }

  /**
   * Decrypts the given data using the underlying private key.
   * @param {Uint8Array|buffer} message - Encrypted blob.
   * @returns {Uint8Array}
   */
  decrypt(message) {
    return ecies.decrypt(this.privateKey, message);    
  }

  /**
   * Creates a digital signature from the provided data.
   * @param {Uint8Array|buffer} message - Binary blob to sign.
   * @returns {string} hexSignature
   */
  sign(message) {
    const buf = message;
    const msg = sha256(buf);
    const sig = secp.sign(msg, this.privateKey);

    return sig.toCompactHex();
  }

}


class RiseMessage {

  /**
   * Protocol headers included in every rise message.
   * @typedef {object} module:rise~RiseMessage~Head
   * @prop {string} nonce - One-time token to prevent replay attacks.
   * @prop {string} version - Version of the rise package. Analagous to user agent.
   * @prop {boolean} ciphertext - Indicates if the message body should be treated 
   * as ciphertext (it is encrypted).
   * @prop {module:rise~RiseSolution} solution - Sender authentication data.
   */

  /**
   * Interface allowing for authenticated message exchange.
   * @constructor
   * @param {module:rise~RiseSolution} solution - Identity solution data.
   * @param {Object.<string, string>} [body] - Key-value data to include.
   * @param {Object.<string, string>} [head] - Additional headers.
   */
  constructor(solution, body = {}, headers = {}) {
    /** 
     * Default message headers, plus any custom ones supplied.
     * @member {module:rise~RiseMessage~Head}
     */
    this.head = {
      nonce: Date.now() + '~' + crypto.randomBytes(8).toString('base64'),
      version: require('./package.json').version,
      ciphertext: false,
      solution,
      ...headers
    };
    /** 
     * Key-value pairs given for the message.
     * @member {Object.<string, string>|string} 
     */
    this.body = Buffer.isBuffer(body)
      ? body.toString('base64')
      : body;
  }

  /**
   * Encrypts the message state for the public key provided.
   * @param {Uint8Array|buffer} publicKey - Recipient public key.
   * @returns {module:rise~EncryptedRiseMessage}
   */
  encrypt(publicKey) {
    const body = this.head.ciphertext
      ? this.body
      : JSON.stringify(this.body);

    return new EncryptedRiseMessage(this.head.solution,
      ecies.encrypt(publicKey, body), this.head);
  }  

  /**
   * Signs the message state using the private key provided.
   * @param {Uint8Array|buffer} privateKey - Identity to use for signature.
   * @returns {module:rise~SignedRiseMessage}
   */
  sign(privateKey) {
    const secret = new RiseSecret(privateKey);
    const buf = this.toBuffer(); 
    const sig = secret.sign(buf);
    const msg = new SignedRiseMessage(this.head.solution, this.body, {
      ...this.head,
      signature: sig
    });

    return msg;
  }

  /**
   * Returns only the body of this message.
   * @returns {Object.<string, string|module:rise~RiseMessage|module:rise~EncryptedRiseMessage|module:rise~SignedRiseMessage|string>}
   */
  unwrap() {
    return this.body;
  }

  /**
   * Ensures the solution header is valid.
   * @returns {boolean}
   */
  validate() {
    return this.head.solution.verify(...arguments);
  }

  /**
   * Serializes the message to wire format.
   * @returns {buffer}
   */
  toBuffer() {
    const magicStr = RiseIdentity.MAGIC.toString('hex'); 
    const head = JSON.stringify(this.head);
    const body = this.head.ciphertext
      ? this.body
      : JSON.stringify(this.body);
    const str = [head, body].join(magicStr);

    return Buffer.from(str);
  }

  /**
   * Creates a new message instance from the serialized message.
   * @param {buffer} message - Binary blob to deserialize.
   * @returns {module:rise~RiseMessage|module:rise~EncryptedRiseMessage|module:rise~SignedRiseMessage}
   */
  static fromBuffer(buffer) {
    const str = buffer.toString();
    const magicStr = RiseIdentity.MAGIC.toString('hex'); 
    const [rawHead, rawBody] = str.split(magicStr);
    const head = JSON.parse(rawHead);
    const body = head.ciphertext
      ? rawBody
      : JSON.parse(rawBody);

    head.solution = RiseSolution.fromJSON(head.solution);

    if (head.signature && head.ciphertext) {
      return new SignedRiseMessage(head.solution, body, head);
    }
    
    if (head.ciphertext) {
      return new EncryptedRiseMessage(head.solution, body, head);
    }
  
    return new RiseMessage(head.solution, body, head);
  }

}


class EncryptedRiseMessage extends RiseMessage {

  /**
   * Interface for an encrypted message.
   * @constructor
   * @extends {module:rise~RiseMessage}
   */
  constructor() {
    super(...arguments);
    this.head.ciphertext = true;
  }

  /**
   * Decrypts the message using the supplied private key.
   * @param {Uint8Array|buffer} privateKey - Private key to use.
   * @returns {module:rise~RiseMessage}
   */
  decrypt(privateKey) {
    const secret = new RiseSecret(privateKey);
    let body = JSON.parse(
      Buffer.from(secret.decrypt(Buffer.from(this.body, 'base64')))
        .toString());
   
    if (body.head && body.body) {

      let { proof, nonce, epoch, pubkey } = body.head.solution;
      const sol = new RiseSolution(
        Buffer.from(proof, 'base64'), 
        parseInt(nonce), 
        Buffer.from(pubkey, 'base64'), 
        Buffer.from(epoch, 'base64')
      );

      body.head.solution = sol;

      if (body.head.signature && body.head.ciphertext) {
        body = new SignedRiseMessage(sol, body.body, body.head);
      } else if (body.head.ciphertext) {
        body = new EncryptedRiseMessage(sol, body.body, body.head);
      } else { 
        body = new RiseMessage(sol, body.body, body.head);
      }
    }

    return new RiseMessage(this.head.solution, body, this.head);
  }

}


class SignedRiseMessage extends EncryptedRiseMessage {

  /**
   * Interface for digitall signed rise message
   * @constructor
   * @extends {module:rise~EncryptedRiseMessage}
   */ 
  constructor() {
    super(...arguments);
  }

  /**
   * Ensures that the digita signature is valid.
   * @returns {boolean}
   */
  verify() {
    const sig = this.head.signature;

    delete this.head.signature;

    const buf = this.toBuffer();
    const msg = sha256(buf);
    const pub = this.head.solution.pubkey;
    const result = secp.verify(sig, msg, pub);

    this.head.signature = sig;

    return result;
  }

}


class RiseSolution {

  /**
   * Interface for identity solutions.
   * @param {buffer} proof - Equihash proof value.
   * @param {number} nonce - Solution nonce.
   * @param {buffer} pubkey - Public key solution was seeded from.
   * @param {buffer} epoch - Magic network number prepended to public key.
   */
  constructor(proof, nonce, pubkey, epoch = RiseIdentity.MAGIC) {
    /** 
     * Equihash proof.
     * @member {buffer} 
     */
    this.proof = proof;
    /** 
     * Solution nonce.
     * @member {nuber} 
     */
    this.nonce = nonce;
    /** 
     * Network magic number.
     * @member {buffer}
     */
    this.epoch = epoch;
    /** 
     * Public key.
     * @member {buffer}
     */
    this.pubkey = Buffer.from(pubkey);
  }

  /** 
   * RIPEMD-160 hash for SHA-256 hash of serialized solution.
   * @member {buffer}
   */
  get fingerprint() {
    return rmd160(sha256(Buffer.from(JSON.stringify(this.toJSON()))));
  }

  /**
   * Number of leading zeroes in the proof.
   * @member {number}
   */
  get difficulty() {
    const binStr = this.getProofAsBinaryString();
    
    for (let i = 0; i < binStr.length; i++) {
      if (binStr[i] !== '0') {
        return i;
      }
    }

    return binStr.length;
  }

  /**
   * Serilaizes the solution into a JSON object.
   * @returns {Object.<string, string>}
   */ 
  toJSON() {
    return {
      proof: this.proof.toString('base64'),
      nonce: this.nonce.toString(),
      pubkey: this.pubkey.toString('base64'),
      epoch: this.epoch.toString('base64')
    };
  }

  /**
   * Constructs a {@link RiseSolution} from a JSON object.
   * @param {Object.<string, string>} json - Serialized solution.
   * @returns {module:rise~RiseSolution}
   */
  static fromJSON(json) {
    return new RiseSolution(Buffer.from(json.proof, 'base64'), parseInt(json.nonce),
      Buffer.from(json.pubkey, 'base64'), Buffer.from(json.epoch, 'base64'));
  }

  /**
   * Ensures that the solution is valid.
   * @param {number} [n=RiseIdentity.N] - Width in bits.
   * @param {number} [k=RiseIdentity.K] - Proof length.
   * @returns {Promise<boolean>}
   */
  verify(n = RiseIdentity.N, k = RiseIdentity.K) {
    return equihash.verify(sha256(Buffer.concat([this.epoch, this.pubkey])),
      this.proof, this.nonce, n, k);
  }

  /**
   * Represents the proof as a string of 1's and 0's.
   * @returns {string}
   */ 
  getProofAsBinaryString() {
    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 = this.proof.toString('hex').toLowerCase();
    const bitmaps = [];

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

    return bitmaps.join('');
  }

}


module.exports.Secret = RiseSecret;
module.exports.Message = RiseMessage;
module.exports.EncryptedMessage = EncryptedRiseMessage;
module.exports.SignedMessage = SignedRiseMessage;
module.exports.Identity = RiseIdentity;
module.exports.Solution = RiseSolution;