Source: JWK.js

'use strict'

/**
 * Dependencies
 * @ignore
 */
const crypto = require('@trust/webcrypto')
const base64url = require('base64url')
const { JWA } = require('@trust/jwa')

/**
 * Module Dependencies
 * @ignore
 */
const { DataError } = require('./errors')

/**
 * JWK
 * @ignore
 */
class JWK {

  /**
   * constructor
   *
   * @class JWK
   *
   * @description
   * JSON Web Key ([IETF RFC7517](https://tools.ietf.org/html/rfc7517))
   *
   * @param  {Object} data
   * @param  {Object} [options={}] - Additional JWK metadata.
   */
  constructor (data, options = {}) {
    if (!data) {
      throw new DataError('Invalid JWK')
    }

    // Handle string input
    if (typeof data === 'string') {
      try {
        data = JSON.parse(data)
      } catch (error) {
        throw new DataError('Invalid JWK JSON String')
      }
    }

    // Handle options input
    let metadata = Object.keys(options)
      .filter(key => {
        let dataValue = data[key]
        let optionsValue = options[key]

        if (dataValue && optionsValue && dataValue !== optionsValue) {
          throw new DataError(`Conflicting '${key}' option`)
        }

        return !dataValue && optionsValue
      })
      .reduce((state, key) => {
        state[key] = options[key]
        return state
      }, {})

    // Assign input
    Object.assign(this, data, metadata)

    // Enforce required properties
    if (!this.alg) {
      throw new DataError('Valid \'alg\' required for JWK')
    }

    if (!this.kid) {
      throw new DataError('Valid \'kid\' required for JWK')
    }
  }

  /**
   * importKey
   *
   * @description
   * Import a JWK from JSON String or a JS Object.
   *
   * @param  {(String|Object)} data
   * @param  {Object} [options] - Additional JWK metadata.
   * @return {Promise.<JWK>} A promise that resolves the JWK instance.
   */
  static importKey (data, options) {
    return Promise.resolve()

      // Calculate Thumbprint
      .then(() => this.thumbprint(data))

      // Create instance
      .then(hash => {
        if (hash && !data.kid && !(options && options.kid)) {
          data.kid = hash
        }

        return new JWK(data, options)
      })

      // Import CryptoKey and assign to JWK
      .then(jwk => JWA.importKey(jwk).then(({ cryptoKey }) => {
        Object.defineProperty(jwk, 'cryptoKey', {
          value: cryptoKey,
          enumerable: false,
          configurable: false
        })

        return jwk
      }))
  }

  /**
   * fromCryptoKey
   *
   * @description
   * Import a JWK from a [WebCrypto CryptoKey](https://github.com/anvilresearch/webcrypto).
   *
   * @param  {CryptoKey} key - [WebCrypto CryptoKey]{@link https://github.com/anvilresearch/webcrypto}.
   * @param  {Object} [options] - Additional JWK metadata.
   * @return {Promise.<JWK>} A promise that resolves the JWK instance.
   */
  static fromCryptoKey (key, options) {
    let rawJwk

    // Export JWK
    return JWA.exportKey('jwk', key)
      .then(data => {
        rawJwk = data
        return this.thumbprint(rawJwk)
      })

      // Create JWK instance and assign CryptoKey
      .then(hash => {
        if (!rawJwk.kid && !(options && options.kid)) {
          rawJwk.kid = hash
        }

        let jwk = new JWK(rawJwk, options)

        Object.defineProperty(jwk, 'cryptoKey', {
          value: key,
          enumerable: false,
          configurable: false
        })

        return jwk
      })
  }

  /**
   * thumbprint
   *
   * @description
   * Calculate the SHA-256 JWK Thumbprint according to [RFC7638]{@link https://tools.ietf.org/html/rfc7638}.
   * This method is used to create a unique `kid` if none is specified.
   *
   * @example <caption>SHA-256 Thumbprint</caption>
   * JWK.thumbprint(jwk)
   *   .then(console.log)
   * //
   * // (line breaks for display only)
   * //
   * // => "45BLsBiWcghaEf_NF70Gf5oQcYLHaA
   * //     tks0C48tT5SJ4"
   *
   * @param  {Object} jwk
   * @return {Promise.<String>} A promise that resolves the JWK Thumbprint String.
   */
  static thumbprint (jwk) {
    let { kty } = jwk
    let data

    // RSA JWK Thumbprint Fields
    if (kty === 'RSA') {
      let { e, n } = jwk
      data = { e, kty, n }

    // ECDSA JWK Thumbprint Fields
    } else if (kty === 'EC') {
      let { crv, x, y } = jwk
      data = { crv, kty, x, y }

    // Symmetric JWK Thumbprint Fields
    } else if (kty === 'oct') {
      let { k } = jwk
      data = { k, kty }

    // Invalid kty
    } else {
      return Promise.reject(new DataError('Invalid \'kty\''))
    }

    let json = JSON.stringify(data)
    return crypto.subtle.digest({ name: 'SHA-256' }, json)
      .then(hash => base64url(hash))
  }

  /**
   * sign
   *
   * @description
   * Sign arbitrary data using the JWK.
   *
   * @example <caption>Signing the string "test"</caption>
   * privateJwk.sign('test')
   *   .then(console.log)
   * //
   * // (line breaks for display only)
   * //
   * // => "MEUCIQCHwnGM8IsOJgfQsoPgs3hMd8
   * //     ahfWHM9ZNvj1K6i2yhKQIgWGOuXX43
   * //     lSTo-U8Pa8sURR53lv6Osjw-dtoLse
   * //     lftqQ"
   *
   * @param  {(String|Buffer)} data - The data to sign.
   * @return {Promise.<String>} A promise that resolves the base64url encoded signature string.
   */
  sign (data) {
    let { alg, cryptoKey } = this
    return JWA.sign(alg, cryptoKey, data)
  }

  /**
   * verify
   *
   * @description
   * Verify a signature using the JWK.
   *
   * @example <caption>Verify a signature of the string "test"</caption>
   * // base64url encoded signature string
   * let signature = `MEUCIQCHwnGM8IsOJgfQsoPgs3hMd8ahfWHM9ZN
   * vj1K6i2yhKQIgWGOuXX43lSTo-U8Pa8sURR53lv6Osjw-dtoLselftqQ`
   *
   * publicJwk.verify('test', signature)
   *   .then(console.log)
   * // => true
   *
   * @param  {(String|Buffer)} data - The data to verify.
   * @param  {String} signature - A base64url signature string.
   * @return {Promise.<Boolean>} A promise that resolves the boolean result of the signature verification.
   */
  verify (data, signature) {
    let { alg, cryptoKey } = this
    return JWA.verify(alg, cryptoKey, signature, data)
  }

  /**
   * encrypt
   *
   * @description
   * Encrypt arbitrary data using the JWK.
   *
   * @example <caption>Encrypt the string "data"</caption>
   * secretJwk.encrypt('data')
   *   .then(console.log)
   * // => { iv: 'u0l3ttqUFDQ8mcRboHv5Vw',
   * //      ciphertext: 'yq3K4w',
   * //      tag: 'fHlZ__uuUnHn0ac-Lnrr-A' }
   *
   * @param  {(String|Object)} data - The data to encrypt.
   * @param  {(String|Buffer)} [aad] - Additional non-encrypted integrity protected data (AES-GCM).
   * @return {Promise.<Object>} A promise that resolves an object containing the base64url encoded `iv`, `ciphertext` and `tag` (AES-GCM).
   */
  encrypt (data, aad) {
    let { alg, cryptoKey } = this
    return JWA.encrypt(alg, cryptoKey, data, aad)
  }

  /**
   * decrypt
   *
   * @description
   * Decrypt data using the JWK.
   *
   * @example <caption>Decrypt encrypted string "test"</caption>
   * // base64url encoded data
   * let ciphertext = 'yq3K4w'
   * let iv = 'u0l3ttqUFDQ8mcRboHv5Vw'
   * let tag = 'fHlZ__uuUnHn0ac-Lnrr-A'
   *
   * secretJwk.decrypt(ciphertext, iv, tag)
   *   .then(console.log)
   * // => "data"
   *
   * @param  {(String|Buffer)} ciphertext - The encrypted data to decrypt.
   * @param  {(String|Buffer)} iv - The initialization vector.
   * @param  {(String|Buffer)} [tag] - The authorization tag (AES-GCM).
   * @param  {(String|Buffer)} [aad] - Additional non-encrypted integrity protected data (AES-GCM).
   * @return {Promise.<String>} A promise that resolves the plaintext data.
   */
  decrypt (ciphertext, iv, tag, aad) {
    let { alg, cryptoKey } = this
    return JWA.decrypt(alg, cryptoKey, ciphertext, iv, tag, aad)
  }

  /**
   * thumbprint
   *
   * @description
   * Calculate the SHA-256 JWK Thumbprint according to [RFC7638]{@link https://tools.ietf.org/html/rfc7638}.
   * This method is used to create a unique `kid` if none is specified.
   *
   * @example <caption>SHA-256 Thumbprint</caption>
   * jwk.thumbprint()
   *   .then(console.log)
   * //
   * // (line breaks for display only)
   * //
   * // => "45BLsBiWcghaEf_NF70Gf5oQcYLHaA
   * //     tks0C48tT5SJ4"
   *
   * @return {Promise.<String>} A promise that resolves the JWK Thumbprint String.
   */
  thumbprint () {
    return JWK.thumbprint(this)
  }

  /**
   * getProtectedHeader
   *
   * @description
   * Use key metadata to generate a JWS protected header object.
   *
   * @example <caption>Basic JWS Header with JWC</caption>
   * jwk.getProtectedHeader({ jwc: 'base64url encoded compact jwc' })
   * // => { alg: 'RS256',
   * //      kid: 'abcd123$',
   * //      jwc: 'base64url encoded compact jwc' }
   *
   * @example <caption>Basic JWS Header with JKU</caption>
   * jwk.getProtectedHeader({ jku: 'https://example.com/jwks' })
   * // => { alg: 'RS256',
   * //      kid: 'abcd123$',
   * //      jku: 'https://example.com/jwks' }
   *
   * @param  {Object} params - Additional properties to include in header.
   * @return {Object} JWS Header
   */
  getProtectedHeader (params) {
    let { alg, kid, key_ops, use } = this
    let header = Object.assign({}, { alg, kid }, params)

    // Check key_ops or use
    if (!(Array.isArray(key_ops) && key_ops.includes('sign')) && !(use && use === 'sig')) {
      throw new DataError('Invalid key usage option')
    }

    // Check for mandatory properties
    if (!header.alg) {
      throw new DataError('\'alg\' is required')
    }

    if (!header.kid) {
      throw new DataError('\'kid\' is required')
    }

    if (!header.jku && !header.jwc) {
      throw new DataError('Either \'jku\' or \'jwc\' is required')
    }

    return header
  }
}

/**
 * Exports
 * @ignore
 */
module.exports = JWK