Source: hd/mnemonic.js

/*!
 * mnemonic.js - hd mnemonics for hsd
 * Copyright (c) 2017-2018, Christopher Jeffrey (MIT License).
 * https://github.com/handshake-org/hsd
 */

'use strict';

const assert = require('bsert');
const bio = require('bufio');
const sha256 = require('bcrypto/lib/sha256');
const cleanse = require('bcrypto/lib/cleanse');
const random = require('bcrypto/lib/random');
const pbkdf2 = require('bcrypto/lib/pbkdf2');
const sha512 = require('bcrypto/lib/sha512');
const wordlist = require('./wordlist');
const common = require('./common');
const nfkd = require('./nfkd');

/** @typedef {import('../types').BufioWriter} BufioWriter */

/*
 * Constants
 */

const wordlistCache = Object.create(null);

/**
 * @typedef {Object} MnemonicOptions
 * @property {Number?} [bits]
 * @property {Buffer?} [entropy]
 * @property {String?} [phrase]
 * @property {String?} [language]
 */

/**
 * HD Mnemonic
 * @alias module:hd.Mnemonic
 */

class Mnemonic extends bio.Struct {
  /**
   * Create a mnemonic.
   * @constructor
   * @param {String|MnemonicOptions} [options]
   */

  constructor(options) {
    super();
    this.bits = 256;
    this.language = 'english';
    this.entropy = null;
    this.phrase = null;

    if (options)
      this.fromOptions(options);
  }

  /**
   * Inject properties from options object.
   * @param {String|MnemonicOptions} options
   */

  fromOptions(options) {
    if (typeof options === 'string')
      options = { phrase: options };

    if (options.bits != null) {
      assert((options.bits & 0xffff) === options.bits);
      assert(options.bits >= common.MIN_ENTROPY);
      assert(options.bits <= common.MAX_ENTROPY);
      assert(options.bits % 32 === 0);
      this.bits = options.bits;
    }

    if (options.language) {
      assert(typeof options.language === 'string');
      if (Mnemonic.languages.indexOf(options.language) === -1)
        throw new Error('Unknown language.');

      this.language = options.language;
    }

    if (options.phrase) {
      this.fromPhrase(options.phrase);
      return this;
    }

    if (options.entropy) {
      this.fromEntropy(options.entropy);
      return this;
    }

    return this;
  }

  /**
   * Destroy the mnemonic (zeroes entropy).
   */

  destroy() {
    this.bits = common.MIN_ENTROPY;
    this.language = 'english';
    if (this.entropy) {
      cleanse(this.entropy);
      this.entropy = null;
    }
    this.phrase = null;
  }

  /**
   * Generate the seed.
   * @param {String?} [passphrase]
   * @returns {Buffer} pbkdf2 seed.
   */

  toSeed(passphrase) {
    if (!passphrase)
      passphrase = '';

    const phrase = nfkd(this.getPhrase());
    const passwd = nfkd(`mnemonic${passphrase}`);

    return pbkdf2.derive(sha512,
      Buffer.from(phrase, 'utf8'),
      Buffer.from(passwd, 'utf8'),
      2048, 64);
  }

  /**
   * Get or generate entropy.
   * @returns {Buffer}
   */

  getEntropy() {
    if (!this.entropy)
      this.entropy = random.randomBytes(this.bits / 8);

    assert(this.bits / 8 === this.entropy.length);

    return this.entropy;
  }

  /**
   * Generate a mnemonic phrase from chosen language.
   * @returns {String}
   */

  getPhrase() {
    if (this.phrase)
      return this.phrase;

    // Include the first `ENT / 32` bits
    // of the hash (the checksum).
    const wbits = this.bits + (this.bits / 32);

    // Get entropy and checksum.
    const entropy = this.getEntropy();
    const chk = sha256.digest(entropy);

    // Append the hash to the entropy to
    // make things easy when grabbing
    // the checksum bits.
    const size = Math.ceil(wbits / 8);
    const data = Buffer.allocUnsafe(size);
    entropy.copy(data, 0);
    chk.copy(data, entropy.length);

    // Build the mnemonic by reading
    // 11 bit indexes from the entropy.
    const list = Mnemonic.getWordlist(this.language);

    /** @type {String[]} */
    const words = [];

    for (let i = 0; i < wbits / 11; i++) {
      let index = 0;
      for (let j = 0; j < 11; j++) {
        const pos = i * 11 + j;
        const bit = pos % 8;
        const oct = (pos - bit) / 8;
        index <<= 1;
        index |= (data[oct] >>> (7 - bit)) & 1;
      }
      words.push(list.words[index]);
    }

    let phrase;
    // Japanese likes double-width spaces.
    if (this.language === 'japanese')
      phrase = words.join('\u3000');
    else
      phrase = words.join(' ');

    this.phrase = phrase;

    return phrase;
  }

  /**
   * Inject properties from phrase.
   * @param {String} phrase
   */

  fromPhrase(phrase) {
    assert(typeof phrase === 'string');
    assert(phrase.length <= 1000);

    const words = phrase.trim().split(/[\s\u3000]+/);
    const wbits = words.length * 11;
    const cbits = wbits % 32;

    assert(cbits !== 0, 'Invalid checksum.');

    const bits = wbits - cbits;

    assert(bits >= common.MIN_ENTROPY);
    assert(bits <= common.MAX_ENTROPY);
    assert(bits % 32 === 0);

    const size = Math.ceil(wbits / 8);
    const data = Buffer.allocUnsafe(size);
    data.fill(0);

    const lang = Mnemonic.getLanguage(words[0]);
    const list = Mnemonic.getWordlist(lang);

    // Rebuild entropy bytes.
    for (let i = 0; i < words.length; i++) {
      const word = words[i];
      const index = list.map[word];

      if (index == null)
        throw new Error('Could not find word.');

      for (let j = 0; j < 11; j++) {
        const pos = i * 11 + j;
        const bit = pos % 8;
        const oct = (pos - bit) / 8;
        const val = (index >>> (10 - j)) & 1;
        data[oct] |= val << (7 - bit);
      }
    }

    const cbytes = Math.ceil(cbits / 8);
    const entropy = data.slice(0, data.length - cbytes);
    const chk1 = data.slice(data.length - cbytes);
    const chk2 = sha256.digest(entropy);

    // Verify checksum.
    for (let i = 0; i < cbits; i++) {
      const bit = i % 8;
      const oct = (i - bit) / 8;
      const b1 = (chk1[oct] >>> (7 - bit)) & 1;
      const b2 = (chk2[oct] >>> (7 - bit)) & 1;
      if (b1 !== b2)
        throw new Error('Invalid checksum.');
    }

    assert(bits / 8 === entropy.length);

    this.bits = bits;
    this.language = lang;
    this.entropy = entropy;

    // Japanese likes double-width spaces.
    if (this.language === 'japanese')
      this.phrase = words.join('\u3000');
    else
      this.phrase = words.join(' ');

    return this;
  }

  /**
   * Instantiate mnemonic from a phrase (validates checksum).
   * @param {String} phrase
   * @returns {Mnemonic}
   * @throws on bad checksum
   */

  static fromPhrase(phrase) {
    return new this().fromPhrase(phrase);
  }

  /**
   * Inject properties from entropy.
   * @private
   * @param {Buffer} entropy
   * @param {String?} [lang]
   */

  fromEntropy(entropy, lang) {
    assert(Buffer.isBuffer(entropy));
    assert(entropy.length * 8 >= common.MIN_ENTROPY);
    assert(entropy.length * 8 <= common.MAX_ENTROPY);
    assert((entropy.length * 8) % 32 === 0);
    assert(!lang || Mnemonic.languages.indexOf(lang) !== -1);

    this.entropy = entropy;
    this.bits = entropy.length * 8;

    if (lang)
      this.language = lang;

    return this;
  }

  /**
   * Instantiate mnemonic from entropy.
   * @param {Buffer} entropy
   * @param {String?} lang
   * @returns {Mnemonic}
   */

  static fromEntropy(entropy, lang) {
    return new this().fromEntropy(entropy, lang);
  }

  /**
   * Determine a single word's language.
   * @param {String} word
   * @returns {String} Language.
   * @throws on not found.
   */

  static getLanguage(word) {
    for (const lang of Mnemonic.languages) {
      const list = Mnemonic.getWordlist(lang);
      if (list.map[word] != null)
        return lang;
    }

    throw new Error('Could not determine language.');
  }

  /**
   * Retrieve the wordlist for a language.
   * @param {String} lang
   * @returns {WordList}
   */

  static getWordlist(lang) {
    const cache = wordlistCache[lang];

    if (cache)
      return cache;

    const words = wordlist.get(lang);
    const list = new WordList(words);

    wordlistCache[lang] = list;

    return list;
  }

  /**
   * Convert mnemonic to a json-friendly object.
   * @returns {Object}
   */

  getJSON() {
    return {
      bits: this.bits,
      language: this.language,
      entropy: this.getEntropy().toString('hex'),
      phrase: this.getPhrase()
    };
  }

  /**
   * Inject properties from json object.
   * @param {Object} json
   */

  fromJSON(json) {
    assert(json);
    assert((json.bits & 0xffff) === json.bits);
    assert(typeof json.language === 'string');
    assert(typeof json.entropy === 'string');
    assert(typeof json.phrase === 'string');
    assert(json.bits >= common.MIN_ENTROPY);
    assert(json.bits <= common.MAX_ENTROPY);
    assert(json.bits % 32 === 0);
    assert(json.bits / 8 === json.entropy.length / 2);

    this.bits = json.bits;
    this.language = json.language;
    this.entropy = Buffer.from(json.entropy, 'hex');
    this.phrase = json.phrase;

    return this;
  }

  /**
   * Calculate serialization size.
   * @returns {Number}
   */

  getSize() {
    let size = 0;
    size += 3;
    size += this.getEntropy().length;
    return size;
  }

  /**
   * Write the mnemonic to a buffer writer.
   * @param {BufioWriter} bw
   * @returns {BufioWriter}
   */

  write(bw) {
    const lang = Mnemonic.languages.indexOf(this.language);

    assert(lang !== -1);

    bw.writeU16(this.bits);
    bw.writeU8(lang);
    bw.writeBytes(this.getEntropy());

    return bw;
  }

  /**
   * Inject properties from buffer reader.
   * @param {bio.BufferReader} br
   */

  read(br) {
    const bits = br.readU16();

    assert(bits >= common.MIN_ENTROPY);
    assert(bits <= common.MAX_ENTROPY);
    assert(bits % 32 === 0);

    const language = Mnemonic.languages[br.readU8()];
    assert(language);

    this.bits = bits;
    this.language = language;
    this.entropy = br.readBytes(bits / 8);

    return this;
  }

  /**
   * Convert the mnemonic to a string.
   * @returns {String}
   */

  toString() {
    return this.getPhrase();
  }

  /**
   * Inspect the mnemonic.
   * @returns {String}
   */

  format() {
    return `<Mnemonic: ${this.getPhrase()}>`;
  }

  /**
   * Test whether an object is a Mnemonic.
   * @param {Object} obj
   * @returns {Boolean}
   */

  static isMnemonic(obj) {
    return obj instanceof Mnemonic;
  }
}

/**
 * List of languages.
 * @const {String[]}
 * @default
 */

Mnemonic.languages = [
  'simplified chinese',
  'traditional chinese',
  'english',
  'french',
  'italian',
  'japanese',
  'portuguese',
  'spanish'
];

/**
 * Word List
 * @ignore
 */

class WordList {
  /**
   * Create word list.
   * @constructor
   * @ignore
   * @param {String[]} words
   */

  constructor(words) {
    this.words = words;
    this.map = Object.create(null);

    for (let i = 0; i < words.length; i++) {
      const word = words[i];
      this.map[word] = i;
    }
  }
}

/*
 * Expose
 */

module.exports = Mnemonic;