Source: index.js

/** @module hcSeedBundle
 *
 */

import _sodium from 'libsodium-wrappers'
import msgpack from 'tiny-msgpack'

const _sodiumCfg = {
  sodiumReady: false
}

/**
 * Await this promise once before calling functions in this library.
 *
 * @type {Promise}
 */
export const seedBundleReady = _sodium.ready.then(() => {
  _sodiumCfg.sodiumReady = true
})

/**
 * Internal helper for ensuring the _sodium lib is ready
 *
 * @private
 */
function checkSodiumReady () {
  if (!_sodiumCfg.sodiumReady) {
    throw new Error('seedBundle library not ready. Await "seedBundleReady" first.')
  }
}

/**
 * Helper class that makes securing secrets easier by hiding them
 * in closures as additional protection against accidental exposure
 * via debugging, etc.
 * Note, when done with this secret, call `zero()` to clear the memory,
 * but be warned that this is javascript, and that is no guarantee
 * against exposure.
 *
 * @private
 */
class PrivSecretBuf {
  constructor (secret) {
    checkSodiumReady()

    if (!(secret instanceof Uint8Array)) {
      throw new Error('secret must be a Uint8Array')
    }

    if (_sodium.is_zero(secret)) {
      throw new Error('secret cannot be a zeroed Uint8Array')
    }

    // setup some config to track our zero status
    const cfg = {
      didZero: false
    }

    // closure to return secret if not zeroed
    const get = () => {
      if (cfg.didZero) {
        throw new Error('cannot access secret, already zeroed')
      }
      return secret
    }

    // closure to zero our secret
    const zero = () => {
      _sodium.memzero(secret)
      cfg.zero = true
    }

    // closure to derive an ed25519 signature pubkey from secret
    // if the secret is a passphrase, this will probably fail
    const deriveSignPubKey = () => {
      if (cfg.didZero) {
        throw new Error('cannot access secret, already zeroed')
      }
      if (secret.length !== 32) {
        throw new Error('can only derive secrets of length 32')
      }

      const { publicKey, privateKey } = _sodium.crypto_sign_seed_keypair(secret)
      _sodium.memzero(privateKey)
      return _sodium.to_base64(publicKey, _sodium.base64_variants.URLSAFE_NO_PADDING)
    }

    // closure to derive a sub-secret
    // if the secret is a passphrase, this will probably fail
    const derive = (subkeyId) => {
      if (cfg.didZero) {
        throw new Error('cannot access secret, already zeroed')
      }
      if (secret.length !== 32) {
        throw new Error('can only derive secrets of length 32')
      }
      const newSecret = _sodium.crypto_kdf_derive_from_key(32, subkeyId, 'SeedBndl', secret)
      return new PrivSecretBuf(newSecret)
    }

    Object.defineProperties(this, {
      get: { value: get },
      zero: { value: zero },
      deriveSignPubKey: { value: deriveSignPubKey },
      derive: { value: derive }
    })

    Object.freeze(this)
  }
}

/**
 * Injest a Uint8Array as an internal secret buffer.
 * Note, this buffer will be zeroed internally.
 *
 * @param {Uint8Array} secret the secret to injest.
 * @returns {PrivSecretBuf}
 */
export function parseSecret (secret) {
  checkSodiumReady()
  return new PrivSecretBuf(secret)
}

/**
 * helper to translate limit names into values
 *
 * @private
 */
function privTxLimits (limitName) {
  let opsLimit = _sodium.crypto_pwhash_OPSLIMIT_MODERATE
  let memLimit = _sodium.crypto_pwhash_MEMLIMIT_MODERATE

  if (limitName === 'minimum') {
    opsLimit = _sodium.crypto_pwhash_OPSLIMIT_MIN
    memLimit = _sodium.crypto_pwhash_MEMLIMIT_MIN
  } else if (limitName === 'interactive') {
    opsLimit = _sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE
    memLimit = _sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE
  } else if (limitName === 'sensitive') {
    opsLimit = _sodium.crypto_pwhash_OPSLIMIT_SENSITIVE
    memLimit = _sodium.crypto_pwhash_MEMLIMIT_SENSITIVE
  } else if (!limitName || limitName === 'moderate') {
    /* pass */
  } else {
    throw new Error('invalid limitName: ' + limitName)
  }

  return { opsLimit, memLimit }
}

/**
 * Base class for concrete SeedCiphers.
 */
export class SeedCipher {
  /**
   * Don't use this directly, use a sub-class.
   */
  constructor () {
    checkSodiumReady()
  }

  /**
   * Clear out any secret data maintained by this cipher
   */
  zero () {
    throw new Error('SeedCipher.zero is not callable on base class')
  }

  /**
   * Generate an encrypted seedCipher for given secretSeed
   *
   * @param {PrivSecretBuf} - parseSecret(Uint8Array)
   * @returns {object}
   */
  encryptSeed (secretSeed) {
    throw new Error('SeedCipher.encryptSeed is not callable on base class')
  }
}

/**
 * Base class for unlocking an encrypted seedCipher.
 */
export class LockedSeedCipher {
  #finishUnlockCb

  /**
   * Don't use this directly, use a sub-class.
   */
  constructor (finishUnlockCb) {
    checkSodiumReady()

    this.#finishUnlockCb = finishUnlockCb
  }

  /**
   * Once the secretSeed is decrypted, subclass instances will call this
   * to generate the actualy UnlockedSeedBundle instance.
   *
   * @param {PrivSecretBuf}
   * @returns {UnlockedSeedBundle}
   */
  finishUnlock (secretSeed) {
    return this.#finishUnlockCb(secretSeed)
  }
}

/**
 * Straight up pwhashed passphrase type SeedCipher
 */
export class SeedCipherPwHash extends SeedCipher {
  #passphrase
  #limitName

  /**
   * Build this with
   *
   * @param {PrivSecretBuf} - parseSecret(Uint8Array)
   * @param {string} [limitName] - optional limitName (['interactive', 'moderate' *default*, 'sensitive'])
   */
  constructor (passphrase, limitName) {
    super()
    if (!(passphrase instanceof PrivSecretBuf)) {
      throw new Error('passphrase required, construct with parseSecret()')
    }
    this.#passphrase = passphrase
    this.#limitName = limitName
  }

  /**
   * Clear secret data
   */
  zero () {
    this.#passphrase.zero()
  }

  /**
   * Encrypte a secretSeed SeedCipher with this instance.
   * @param {PrivSecretBuf} - parseSecret(Uint8Array)
   * @returns {object}
   */
  encryptSeed (secretSeed) {
    if (!(secretSeed instanceof PrivSecretBuf)) {
      throw new Error('secretSeed must be an internal secret buffer')
    }

    const pwHash = _sodium.crypto_generichash(64, this.#passphrase.get())

    const salt = _sodium.randombytes_buf(16)

    const { opsLimit, memLimit } = privTxLimits(this.#limitName)

    // generate secret from pwhash
    const secret = _sodium.crypto_pwhash(
      32,
      pwHash,
      salt,
      opsLimit,
      memLimit,
      _sodium.crypto_pwhash_ALG_ARGON2ID13
    )

    _sodium.memzero(pwHash)

    // initialize encryption
    const { state, header } = _sodium
      .crypto_secretstream_xchacha20poly1305_init_push(secret)

    _sodium.memzero(secret)

    // encrypt our inner secret data
    const cipher = _sodium.crypto_secretstream_xchacha20poly1305_push(
      state,
      secretSeed.get(),
      null,
      _sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
    )

    return [
      'pw',
      salt,
      memLimit,
      opsLimit,
      header,
      cipher
    ]
  }
}

function privNormalizeSecurityAnswers (answers) {
  // somehow standard is failing to recognize the usage in the loop
  // eslint-disable-next-line no-unused-vars
  for (const a of answers) {
    if (!(a instanceof PrivSecretBuf)) {
      throw new Error('answer must be construct with parseSecret()')
    }
  }

  // is there a more secure way to do this lcase / trimming / encoding??
  for (let ai = 0; ai < answers.length; ++ai) {
    const s = (new TextDecoder()).decode(answers[ai].get())
    answers[ai].zero()
    answers[ai] = (new TextEncoder()).encode(s.toLowerCase().trim())
  }
  const total = answers[0].length + answers[1].length + answers[2].length

  const answerBlob = new Uint8Array(total)
  answerBlob.set(answers[0])
  answerBlob.set(answers[1], answers[0].length)
  answerBlob.set(answers[2], answers[0].length + answers[1].length)

  _sodium.memzero(answers[0])
  _sodium.memzero(answers[1])
  _sodium.memzero(answers[2])

  return parseSecret(answerBlob)
}

/**
 * SeedCipher locked by three security question answers
 */
export class SeedCipherSecurityQuestions extends SeedCipher {
  #questionList
  #answerBlob
  #limitName

  /**
   * Build this with
   *
   * @param {string[]} - 3 security questions
   * @param {PrivSecretBuf[]} - 3 security answers (parseSecret(Uint8Array))
   * @param {string} [limitName] - optional limitName (['interactive', 'moderate' *default*, 'sensitive'])
   */
  constructor (questions, answers, limitName) {
    super()
    if (
      !Array.isArray(questions) ||
      !Array.isArray(answers) ||
      questions.length !== 3 ||
      answers.length !== 3
    ) {
      throw new Error('require 3 questions and 3 answers')
    }

    this.#questionList = questions
    this.#answerBlob = privNormalizeSecurityAnswers(answers)
    this.#limitName = limitName
  }

  /**
   * Clear secret data
   */
  zero () {
    this.#answerBlob.zero()
  }

  /**
   * Encrypte a secretSeed SeedCipher with this instance.
   * @param {PrivSecretBuf} - parseSecret(Uint8Array)
   * @returns {object}
   */
  encryptSeed (secretSeed) {
    if (!(secretSeed instanceof PrivSecretBuf)) {
      throw new Error('secretSeed must be an internal secret buffer')
    }

    const pwHash = _sodium.crypto_generichash(64, this.#answerBlob.get())

    const salt = _sodium.randombytes_buf(16)

    const { opsLimit, memLimit } = privTxLimits(this.#limitName)

    // generate secret from pwhash
    const secret = _sodium.crypto_pwhash(
      32,
      pwHash,
      salt,
      opsLimit,
      memLimit,
      _sodium.crypto_pwhash_ALG_ARGON2ID13
    )

    _sodium.memzero(pwHash)

    // initialize encryption
    const { state, header } = _sodium
      .crypto_secretstream_xchacha20poly1305_init_push(secret)

    _sodium.memzero(secret)

    // encrypt our inner secret data
    const cipher = _sodium.crypto_secretstream_xchacha20poly1305_push(
      state,
      secretSeed.get(),
      null,
      _sodium.crypto_secretstream_xchacha20poly1305_TAG_FINAL
    )

    return [
      'qa',
      salt,
      memLimit,
      opsLimit,
      this.#questionList[0],
      this.#questionList[1],
      this.#questionList[2],
      header,
      cipher
    ]
  }
}

/**
 * Unlock a SeedCipher with a straight forward pwhashed passphrase.
 */
export class LockedSeedCipherPwHash extends LockedSeedCipher {
  #salt
  #memLimit
  #opsLimit
  #header
  #cipher

  /**
   * You won't use this directly, call UnlockedSeedBundle.fromLocked()
   *
   * @param {function} - the finishUnlock callback function
   * @param {Uint8Array} - argon salt
   * @param {number} - argon memLimit
   * @param {number} - argon opsLimit
   * @param {Uint8Array} - secretstream header
   * @param {Uint8Array} - secretstream cipher
   */
  constructor (finishUnlockCb, salt, memLimit, opsLimit, header, cipher) {
    super(finishUnlockCb)
    this.#salt = salt
    this.#memLimit = memLimit
    this.#opsLimit = opsLimit
    this.#header = header
    this.#cipher = cipher
  }

  /**
   * Unlock to an UnlockedSeedBundle
   *
   * @param {PrivSecretBuf} - parseSecret(Uint8Array)
   * @returns {UnlockedSeedBundle}
   */
  unlock (passphrase) {
    if (!(passphrase instanceof PrivSecretBuf)) {
      throw new Error('passphrase required, construct with parseSecret()')
    }

    const pwHash = _sodium.crypto_generichash(64, passphrase.get())

    passphrase.zero()

    // generate secret from pwhash
    const secret = _sodium.crypto_pwhash(
      32,
      pwHash,
      this.#salt,
      this.#opsLimit,
      this.#memLimit,
      _sodium.crypto_pwhash_ALG_ARGON2ID13
    )

    _sodium.memzero(pwHash)

    // initialize decryption
    const state = _sodium.crypto_secretstream_xchacha20poly1305_init_pull(
      this.#header,
      secret
    )

    _sodium.memzero(secret)

    // finalize decryption
    const res = _sodium.crypto_secretstream_xchacha20poly1305_pull(state, this.#cipher)
    if (!res) {
      throw new Error('failed to decrypt bundle')
    }
    const { message } = res

    return this.finishUnlock(parseSecret(message))
  }
}

/**
 * Unlock a SeedCipher with three security question answers.
 */
export class LockedSeedCipherSecurityQuestions extends LockedSeedCipher {
  #salt
  #memLimit
  #opsLimit
  #questionList
  #header
  #cipher

  /**
   * You won't use this directly, call UnlockedSeedBundle.fromLocked()
   *
   * @param {function} - the finishUnlock callback function
   * @param {Uint8Array} - argon salt
   * @param {number} - argon memLimit
   * @param {number} - argon opsLimit
   * @param {string[]} - the list of security questions
   * @param {Uint8Array} - secretstream header
   * @param {Uint8Array} - secretstream cipher
   */
  constructor (finishUnlockCb, salt, memLimit, opsLimit, questionList, header, cipher) {
    super(finishUnlockCb)
    this.#salt = salt
    this.#memLimit = memLimit
    this.#opsLimit = opsLimit
    this.#questionList = questionList
    this.#header = header
    this.#cipher = cipher
  }

  /**
   * List the security questions that should be answered.
   *
   * @returns {string[]}
   */
  getQuestionList () {
    return this.#questionList.slice()
  }

  /**
   * Unlock to an UnlockedSeedBundle
   *
   * @param {PrivSecretBuf[]} - 3 security answers (parseSecret(Uint8Array))
   * @returns {UnlockedSeedBundle}
   */
  unlock (answers) {
    if (
      !Array.isArray(answers) ||
      answers.length !== 3
    ) {
      throw new Error('require 3 answers')
    }

    const answerBlob = privNormalizeSecurityAnswers(answers)
    const pwHash = _sodium.crypto_generichash(64, answerBlob.get())
    answerBlob.zero()

    // generate secret from pwhash
    const secret = _sodium.crypto_pwhash(
      32,
      pwHash,
      this.#salt,
      this.#opsLimit,
      this.#memLimit,
      _sodium.crypto_pwhash_ALG_ARGON2ID13
    )

    _sodium.memzero(pwHash)

    // initialize decryption
    const state = _sodium.crypto_secretstream_xchacha20poly1305_init_pull(
      this.#header,
      secret
    )

    _sodium.memzero(secret)

    // finalize decryption
    const res = _sodium.crypto_secretstream_xchacha20poly1305_pull(state, this.#cipher)
    if (!res) {
      throw new Error('failed to decrypt bundle')
    }
    const { message } = res

    return this.finishUnlock(parseSecret(message))
  }
}

/**
 * Represents a seed bundle with access to secret seeds for derivation.
 *
 * WARNING: Before forgetting about an UnlockedKeyBundle instance, you
 * should probably call the `zero` function to clear the internal secret data.
 * HOWEVER, being javascript, there is no guarantee we haven't leaked
 * secret data. You may want to consider using the rust library for seed
 * generation and derivation.
 */
export class UnlockedSeedBundle {
  // the secret buffer is stored here
  #secret

  /**
   * the base64 encoded public key associated with this seed
   *
   * @instance
   * @type {string}
   */
  signPubKey

  /**
   * any app / user data to provide context for this particular seed
   *
   * @instance
   * @type {object}
   */
  appData = {}

  /**
   * You should not use this constructor directly.
   * Use one of:
   *  - `UnlockedKeyBundle.newRandom(appData)`
   *  - `UnlockedKeyBundle.fromLocked(encodedBytes)`
   * WARNING: see class-level note about zeroing / secrets.
   *
   * @param {PrivSecretBuf} - parseSecret(Uint8Array)
   * @param {object} - appData to associate with bundle
   */
  constructor (secret, appData) {
    checkSodiumReady()

    if (!(secret instanceof PrivSecretBuf)) {
      throw new Error("invalid inner type. use 'newRandom()' or 'fromLocked()'")
    }

    this.#secret = secret
    Object.defineProperty(this, 'signPubKey', {
      value: secret.deriveSignPubKey(),
      writable: false
    })
    if (appData) {
      this.appData = appData
    }
  }

  /**
   * Construct a new completely random root seed with given app / user data.
   * WARNING: see class-level note about zeroing / secrets.
   *
   * @param {object} - appData to associate with bundle
   * @returns {UnlockedSeedBundle}
   */
  static newRandom (appData) {
    checkSodiumReady()
    const secret = parseSecret(_sodium.randombytes_buf(32))
    return new UnlockedSeedBundle(secret, appData)
  }

  /**
   * Extract the LockedSeedCipher list capable of decrypting
   * an UnlockedSeedBundle from an encrypted SeedBundle.
   * WARNING: see class-level note about zeroing / secrets.
   *
   * @param {Uint8Array} - encoded bytes to decode / decrypt
   * @returns {LockedSeedCipher[]}
   */
  static fromLocked (encodedBytes) {
    const decoded = msgpack.decode(encodedBytes)
    if (!Array.isArray(decoded) || decoded[0] !== 'hcsb0') {
      throw new Error('invalid bundle, got: ' + JSON.stringify(decoded))
    }

    const appData = decoded[2].length ? msgpack.decode(decoded[2]) : {}
    const finishUnlockCb = (secretSeed) => {
      return new UnlockedSeedBundle(secretSeed, appData)
    }

    const outList = []

    // somehow standard is failing to recognize the usage in the loop
    // eslint-disable-next-line no-unused-vars
    for (const seedCipher of decoded[1]) {
      if (seedCipher[0] === 'pw') {
        const salt = seedCipher[1]
        const memLimit = seedCipher[2]
        const opsLimit = seedCipher[3]
        const header = seedCipher[4]
        const cipher = seedCipher[5]
        outList.push(new LockedSeedCipherPwHash(finishUnlockCb, salt, memLimit, opsLimit, header, cipher))
      } else if (seedCipher[0] === 'qa') {
        const salt = seedCipher[1]
        const memLimit = seedCipher[2]
        const opsLimit = seedCipher[3]
        const q1 = seedCipher[4]
        const q2 = seedCipher[5]
        const q3 = seedCipher[6]
        const header = seedCipher[7]
        const cipher = seedCipher[8]
        outList.push(new LockedSeedCipherSecurityQuestions(finishUnlockCb, salt, memLimit, opsLimit, [q1, q2, q3], header, cipher))
      } else {
        throw new Error('unrecognized seedCipher type: ' + seedCipher[0])
      }
    }

    return outList
  }

  /**
   * You may change the app/user data with this function.
   *
   * @param {object} - appData to associate with bundle
   */
  setAppData (appData) {
    this.appData = appData
  }

  /**
   * Zero out the internal secret buffers.
   * WARNING: see class-level note about zeroing / secrets.
   */
  zero () {
    this.#secret.zero()
  }

  /**
   * Derive a subkey / seed from this seed bundle seed.
   * WARNING: see class-level note about zeroing / secrets.
   *
   * @param {number} - derivation subkeyId
   * @param {object} - appData to associate with subseed bundle
   * @returns {UnlockedSeedBundle}
   */
  derive (subkeyId, appData) {
    const next = this.#secret.derive(subkeyId)
    return new UnlockedSeedBundle(next, appData)
  }

  /**
   * Encrypt this seed into seed bundle bytes with given
   * seedCipherList - note, all seedCiphers will be zeroed.
   * WARNING: see class-level note about zeroing / secrets.
   *
   * @param {SeedCipher[]} - list of seed ciphers to encrypt into the bundle
   * @returns {Uint8Array}
   */
  lock (seedCipherList) {
    if (!Array.isArray(seedCipherList)) {
      throw new Error('seedCipherList must be an array')
    }

    const encodedSeedCipherList = []

    // somehow standard is failing to recognize the usage in the loop
    // eslint-disable-next-line no-unused-vars
    for (const seedCipher of seedCipherList) {
      if (!(seedCipher instanceof SeedCipher)) {
        throw new Error('seedCipher must be instanceof SeedCipher')
      }
      encodedSeedCipherList.push(seedCipher.encryptSeed(this.#secret))
      seedCipher.zero()
    }

    const bundle = [
      'hcsb0',
      encodedSeedCipherList,
      msgpack.encode(this.appData)
    ]

    return msgpack.encode(bundle)
  }
}