lib/condition.js

'use strict'

/**
 * @module types
 */

const TypeRegistry = require('./type-registry')
const PrefixError = require('../errors/prefix-error')
const ParseError = require('../errors/parse-error')
const MissingDataError = require('../errors/missing-data-error')

const base64url = require('../util/base64url')
const Reader = require('./reader')
const Writer = require('../lib/writer')

// Regex for validating conditions
//
// This is a generic, future-proof version of the crypto-condition regular
// expression.
const CONDITION_REGEX = /^cc:([1-9a-f][0-9a-f]{0,3}|0):[1-9a-f][0-9a-f]{0,15}:[a-zA-Z0-9_-]{0,86}:([1-9][0-9]{0,17}|0)$/

// This is a stricter version based on limitations of the current
// implementation. Specifically, we can't handle bitmasks greater than 32 bits.
const CONDITION_REGEX_STRICT = /^cc:([1-9a-f][0-9a-f]{0,3}|0):[1-9a-f][0-9a-f]{0,7}:[a-zA-Z0-9_-]{0,86}:([1-9][0-9]{0,17}|0)$/

/**
 * Crypto-condition.
 *
 * A primary design goal of crypto-conditions was to keep the size of conditions
 * constant. Even a complex multi-signature can be represented by the same size
 * condition as a simple hashlock.
 *
 * However, this means that a condition only carries the absolute minimum
 * information required. It does not tell you anything about its structure.
 *
 * All that is included with a condition is the fingerprint (usually a hash of
 * the parts of the fulfillment that are known up-front, e.g. public keys), the
 * maximum fulfillment size, the set of features used and the condition type.
 *
 * This information is just enough that an implementation can tell with
 * certainty whether it would be able to process the corresponding fulfillment.
 */
class Condition {
  /**
   * Create a Condition object from a URI.
   *
   * This method will parse a condition URI and construct a corresponding
   * Condition object.
   *
   * @param {String} serializedCondition URI representing the condition
   * @return {Condition} Resulting object
   */
  static fromUri (serializedCondition) {
    if (serializedCondition instanceof Condition) {
      return serializedCondition
    } else if (typeof serializedCondition !== 'string') {
      throw new Error('Serialized condition must be a string')
    }

    const pieces = serializedCondition.split(':')
    if (pieces[0] !== 'cc') {
      throw new PrefixError('Serialized condition must start with "cc:"')
    }

    if (!Condition.REGEX_STRICT.exec(serializedCondition)) {
      throw new ParseError('Invalid condition format')
    }

    const condition = new Condition()
    condition.setTypeId(parseInt(pieces[1], 16))
    condition.setBitmask(parseInt(pieces[2], 16))
    condition.setHash(base64url.decode(pieces[3]))
    condition.setMaxFulfillmentLength(parseInt(pieces[4], 10))

    return condition
  }

  /**
   * Create a Condition object from a binary blob.
   *
   * This method will parse a stream of binary data and construct a
   * corresponding Condition object.
   *
   * @param {Reader} reader Binary stream implementing the Reader interface
   * @return {Condition} Resulting object
   */
  static fromBinary (reader) {
    reader = Reader.from(reader)

    // Instantiate condition
    const condition = new Condition()
    condition.parseBinary(reader)

    return condition
  }

  /**
   * Return the type of this condition.
   *
   * The type is a unique integer ID assigned to each type of condition.
   *
   * @return {Number} Type corresponding to this condition.
   */
  getTypeId () {
    return this.type
  }

  /**
   * Set the type.
   *
   * Sets the type ID for this condition.
   *
   * @param {Number} type Integer representation of type.
   */
  setTypeId (type) {
    this.type = type
  }

  /**
   * Return the bitmask of this condition.
   *
   * For simple condition types this is simply the set of bits representing the
   * features required by the condition type.
   *
   * For structural conditions, this is the bitwise OR of the bitmasks of the
   * condition and all its subconditions, recursively.
   *
   * @return {Number} Bitmask required to verify this condition.
   */
  getBitmask () {
    return this.bitmask
  }

  /**
   * Set the bitmask.
   *
   * Sets the required bitmask to validate a fulfillment for this condition.
   *
   * @param {Number} bitmask Integer representation of bitmask.
   */
  setBitmask (bitmask) {
    this.bitmask = bitmask
  }

  /**
   * Return the hash of the condition.
   *
   * A primary component of all conditions is the hash. It encodes the static
   * properties of the condition. This method enables the conditions to be
   * constant size, no matter how complex they actually are. The data used to
   * generate the hash consists of all the static properties of the condition
   * and is provided later as part of the fulfillment.
   *
   * @return {Buffer} Hash of the condition
   */
  getHash () {
    if (!this.hash) {
      throw new MissingDataError('Maximum fulfillment length not set')
    }

    return this.hash
  }

  /**
   * Validate and set the hash of this condition.
   *
   * Typically conditions are generated from fulfillments and the hash is
   * calculated automatically. However, sometimes it may be necessary to
   * construct a condition URI from a known hash. This method enables that case.
   *
   * @param {Buffer} hash Hash as binary.
   */
  setHash (hash) {
    if (!Buffer.isBuffer(hash)) {
      throw new Error('Hash must be a Buffer')
    }

    this.hash = hash
  }

  /**
   * Return the maximum fulfillment length.
   *
   * The maximum fulfillment length is the maximum allowed length for any
   * fulfillment payload to fulfill this condition.
   *
   * The condition defines a maximum fulfillment length which all
   * implementations will enforce. This allows implementations to verify that
   * their local maximum fulfillment size is guaranteed to accomodate any
   * possible fulfillment for this condition.
   *
   * Otherwise an attacker could craft a fulfillment which exceeds the maximum
   * size of one implementation, but meets the maximum size of another, thereby
   * violating the fundamental property that fulfillments are either valid
   * everywhere or nowhere.
   *
   * @return {Number} Maximum length (in bytes) of any fulfillment payload that
   *   fulfills this condition..
   */
  getMaxFulfillmentLength () {
    if (typeof this.maxFulfillmentLength !== 'number') {
      throw new MissingDataError('Maximum fulfillment length not set')
    }

    return this.maxFulfillmentLength
  }

  /**
   * Set the maximum fulfillment length.
   *
   * The maximum fulfillment length is normally calculated automatically, when
   * calling `Fulfillment#getCondition`. However, when
   *
   * @param {Number} Maximum fulfillment payload length in bytes.
   */
  setMaxFulfillmentLength (maxFulfillmentLength) {
    this.maxFulfillmentLength = maxFulfillmentLength
  }

  /**
   * Generate the URI form encoding of this condition.
   *
   * Turns the condition into a URI containing only URL-safe characters. This
   * format is convenient for passing around conditions in URLs, JSON and other
   * text-based formats.
   *
   * @return {String} Condition as a URI
   */
  serializeUri () {
    return 'cc' +
      ':' + this.getTypeId().toString(16) +
      ':' + this.getBitmask().toString(16) +
      ':' + base64url.encode(this.getHash()) +
      ':' + this.getMaxFulfillmentLength()
  }

  /**
   * Serialize condition to a buffer.
   *
   * Encodes the condition as a string of bytes. This is used internally for
   * encoding subconditions, but can also be used to passing around conditions
   * in a binary protocol for instance.
   *
   * @return {Buffer} Serialized condition
   */
  serializeBinary () {
    const writer = new Writer()
    writer.writeUInt16(this.getTypeId())                // type
    writer.writeVarUInt(this.getBitmask())              // requiredSuites
    writer.writeVarOctetString(this.getHash())          // fingerprint
    writer.writeVarUInt(this.getMaxFulfillmentLength()) // maxFulfillmentLength
    return writer.getBuffer()
  }

  /**
   * Parse any condition in binary format.
   *
   * Will populate the condition object with data from the provided binary
   * stream.
   *
   * @param {Reader} reader Binary stream containing the condition.
   *
   * @private
   */
  parseBinary (reader) {
    this.setTypeId(reader.readUInt16())
    this.setBitmask(reader.readVarUInt())
    // TODO Ensure bitmask is supported?
    this.setHash(reader.readVarOctetString())
    this.setMaxFulfillmentLength(reader.readVarUInt())
  }

  /**
   * Ensure the condition is valid according the local rules.
   *
   * Checks the condition against the local bitmask (supported condition types)
   * and the local maximum fulfillment size.
   *
   * @return {Boolean} Whether the condition is valid according to local rules.
   */
  validate () {
    // Get class for type ID, throws on error
    TypeRegistry.getClassFromTypeId(this.getTypeId())

    // Bitmask can have at most 32 bits with current implementation
    if (this.getBitmask() > Condition.MAX_SAFE_BITMASK) {
      throw new Error('Bitmask too large to be safely represented')
    }

    // Assert all requested features are supported by this implementation
    if (this.getBitmask() & ~Condition.SUPPORTED_BITMASK) {
      throw new Error('Condition requested unsupported feature suites')
    }

    // Assert the requested fulfillment size is supported by this implementation
    if (this.getMaxFulfillmentLength() > Condition.MAX_FULFILLMENT_LENGTH) {
      throw new Error('Condition requested too large of a max fulfillment size')
    }

    return true
  }
}

// Our current implementation can only represent up to 32 bits for our bitmask
Condition.MAX_SAFE_BITMASK = 0xffffffff

// Feature suites supported by this implementation
Condition.SUPPORTED_BITMASK = 0x3f

// Max fulfillment size supported by this implementation
Condition.MAX_FULFILLMENT_LENGTH = 65535

// Expose regular expressions
Condition.REGEX = CONDITION_REGEX
Condition.REGEX_STRICT = CONDITION_REGEX_STRICT

module.exports = Condition