classes/MFKDFDerivedKey/crypto.js

/**
 * @file Multi-Factor Derived Key Crypto Functions
 * @copyright Multifactor 2022 All Rights Reserved
 *
 * @description
 * Cryptographic operations for a multi-factor derived key
 *
 * @author Vivek Nair (https://nair.me) <[email protected]>
 */

const { hkdf } = require('@panva/hkdf')
const crypto = require('crypto')
const getKeyPairFromSeed = require('human-crypto-keys').getKeyPairFromSeed
let subtle
/* istanbul ignore next */
if (typeof window !== 'undefined') {
  subtle = window.crypto.subtle
} else {
  subtle = crypto.webcrypto.subtle
}

/**
 * Create a sub-key of specified size and purpose using HKDF
 *
 * @example
 * // setup multi-factor derived key
 * const key = await mfkdf.setup.key([ await mfkdf.setup.factors.password('password') ])
 *
 * // get 16-byte sub-key for "eth" using hkdf/sha256
 * const subkey = await key.getSubkey(16, 'eth', 'sha256')
 * subkey.toString('hex') // -> 54ad9e5acbc1c33b08a15dd79126e9c9
 *
 * @param {number} [size] - The size of sub-key to derive in bytes; same as base key by default
 * @param {string} [purpose=''] - Factors used to derive this key
 * @param {string} [digest='sha512'] - HKDF digest to use; sha1, sha256, sha384, or sha512
 * @returns {Buffer} Derived sub-key
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.10.0
 * @memberOf MFKDFDerivedKey
 * @async
 */
async function getSubkey (size = this.policy.size, purpose = '', digest = 'sha512') {
  const tag = digest + ';' + size + ';' + purpose
  if (this.subkeys[tag]) return this.subkeys[tag]
  const result = Buffer.from(await hkdf(digest, this.key, '', purpose, size))
  this.subkeys[tag] = result
  return result
}
module.exports.getSubkey = getSubkey

/**
 * Create a symmetric sub-key of specified type
 *
 * @example
 * // setup multi-factor derived key
 * const key = await mfkdf.setup.key([ await mfkdf.setup.factors.password('password') ])
 *
 * // get 16-byte AES128 sub-key
 * const subkey = await key.getSymmetricKey('aes128')
 * subkey.toString('hex') // -> c985454e008e5ecc695e865d339cb2be
 *
 * @param {string} [type='aes256'] - Type of key to generate; des, 3des, aes128, aes192, or aes256
 * @param {boolean} [auth=false] - Whether this is being used for authentication
 * @returns {Buffer} Derived sub-key as a Buffer
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.10.0
 * @memberOf MFKDFDerivedKey
 * @async
 */
async function getSymmetricKey (type = 'aes256', auth = false) {
  type = type.toLowerCase()
  if (type === 'des') { // DES
    return await this.getSubkey(8, auth ? 'DESAUTH' : 'DES', 'sha256')
  } else if (type === '3des') { // 3DES
    return await this.getSubkey(24, auth ? '3DESAUTH' : '3DES', 'sha256')
  } else if (type === 'aes128') { // AES 128
    return await this.getSubkey(16, auth ? 'AES128AUTH' : 'AES128', 'sha256')
  } else if (type === 'aes192') { // AES 192
    return await this.getSubkey(24, auth ? 'AES192AUTH' : 'AES192', 'sha256')
  } else if (type === 'aes256') { // AES 256
    return await this.getSubkey(32, auth ? 'AES256AUTH' : 'AES256', 'sha256')
  } else {
    throw new RangeError('unknown type: ' + type)
  }
}
module.exports.getSymmetricKey = getSymmetricKey

/**
 * Create an asymmetric sub-key pair of specified type
 *
 * @example
 * // setup multi-factor derived key
 * const key = await mfkdf.setup.key([ await mfkdf.setup.factors.password('password') ])
 *
 * // get 16-byte RSA1024 sub-key
 * const subkey = await key.getAsymmetricKeyPair('rsa1024') // -> { privateKey: Uint8Array, publicKey: Uint8Array }
 *
 * @param {string} [type='rsa3072'] - Type of key to generate; ed25519, rsa1024, rsa2048, or rsa3072
 * @param {boolean} [auth=false] - Whether this is being used for authentication
 * @returns {Object} Public key (spki-der encoded) and private key (pkcs8-der encoded)
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.11.0
 * @memberOf MFKDFDerivedKey
 * @async
 */
async function getAsymmetricKeyPair (type = 'rsa3072', auth = false) {
  type = type.toLowerCase()
  const format = { privateKeyFormat: 'pkcs8-der', publicKeyFormat: 'spki-der' }
  if (type === 'ed25519') { // ed25519
    const material = await this.getSubkey(32, auth ? 'ED25519AUTH' : 'ED25519', 'sha256')
    return await getKeyPairFromSeed(material, { id: 'ed25519' }, format)
  } else if (type === 'rsa1024') { // RSA 1024
    const material = await this.getSubkey(32, auth ? 'RSA1024AUTH' : 'RSA1024', 'sha256')
    return await getKeyPairFromSeed(material, { id: 'rsa', modulusLength: 1024 }, format)
  } else if (type === 'rsa2048') { // RSA 2048
    const material = await this.getSubkey(32, auth ? 'RSA2048AUTH' : 'RSA2048', 'sha256')
    return await getKeyPairFromSeed(material, { id: 'rsa', modulusLength: 2048 }, format)
  } else if (type === 'rsa3072') { // RSA 3072
    const material = await this.getSubkey(48, auth ? 'RSA3072AUTH' : 'RSA3072', 'sha256')
    return await getKeyPairFromSeed(material, { id: 'rsa', modulusLength: 3072 }, format)
  } else {
    throw new RangeError('unknown type: ' + type)
  }
}
module.exports.getAsymmetricKeyPair = getAsymmetricKeyPair

/**
 * Sign a message with this key
 *
 * @example
 * // setup multi-factor derived key
 * const key = await mfkdf.setup.key([ await mfkdf.setup.factors.password('password') ])
 *
 * // sign message using RSA-1024
 * const signature = await key.sign('hello world', 'rsa1024')
 *
 * // verify signature using RSA-1024
 * const valid = await key.verify('hello world', signature, 'rsa1024') // -> true
 *
 * @param {string|Buffer} message - The message to sign
 * @param {string} [method='rsa3072'] - Signature method to use; rsa1024, rsa2048, or rsa3072
 * @param {boolean} [auth=false] - Whether this is being used for authentication
 * @returns {Buffer} The signed message
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.11.0
 * @memberOf MFKDFDerivedKey
 * @async
 */
async function sign (message, method = 'rsa3072', auth = false) {
  if (typeof message === 'string') message = Buffer.from(message)
  if (!(Buffer.isBuffer(message))) throw new TypeError('message must be a buffer')
  method = method.toLowerCase()

  const key = await this.getAsymmetricKeyPair(method, auth)

  const cryptoKey = await subtle.importKey('pkcs8', key.privateKey, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'])
  const signature = await subtle.sign({ name: 'RSASSA-PKCS1-v1_5' }, cryptoKey, message)

  return Buffer.from(signature)
}
module.exports.sign = sign

/**
 * Verify a message signed with this key
 *
 * @example
 * // setup multi-factor derived key
 * const key = await mfkdf.setup.key([ await mfkdf.setup.factors.password('password') ])
 *
 * // sign message using RSA-1024
 * const signature = await key.sign('hello world', 'rsa1024')
 *
 * // verify signature using RSA-1024
 * const valid = await key.verify('hello world', signature, 'rsa1024') // -> true
 *
 * @param {string|Buffer} message - The message this signature corresponds to
 * @param {Buffer} signature - The signature to verify
 * @param {string} [method='rsa3072'] - Signature method to use; rsa1024, rsa2048, or rsa3072
 * @returns {boolean} Whether the signature is valid
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.11.0
 * @memberOf MFKDFDerivedKey
 * @async
 */
async function verify (message, signature, method = 'rsa3072') {
  if (typeof message === 'string') message = Buffer.from(message)
  if (!(Buffer.isBuffer(message))) throw new TypeError('message must be a buffer')
  method = method.toLowerCase()

  const key = await this.getAsymmetricKeyPair(method)

  const cryptoKey = await subtle.importKey('spki', key.publicKey, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify'])
  return await subtle.verify({ name: 'RSASSA-PKCS1-v1_5' }, cryptoKey, signature, message)
}
module.exports.verify = verify

/**
 * Encrypt a message with this key
 *
 * Note: DES is not supported on Node.js v18 and later
 *
 * @example
 * // setup multi-factor derived key
 * const key = await mfkdf.setup.key([ await mfkdf.setup.factors.password('password') ])
 *
 * // encrypt message using 3DES
 * const encrypted = await key.encrypt('hello world', '3des')
 *
 * // decrypt message using 3DES
 * const decrypted = await key.decrypt(encrypted, '3des')
 * decrypted.toString() // -> hello world
 *
 * @param {string|Buffer} message - The message to encrypt
 * @param {string} [method='aes256'] - Encryption method to use; rsa1024, rsa2048, des, 3des, aes128, aes192, or aes256
 * @param {string} [mode='CBC'] - Encryption mode to use; ECB, CFB, OFB, GCM, CTR, or CBC
 * @param {boolean} [auth=false] - Whether this is being used for authentication
 * @returns {Buffer} The encrypted message
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.10.0
 * @memberOf MFKDFDerivedKey
 * @async
 */
async function encrypt (message, method = 'aes256', mode = 'CBC', auth = false) {
  if (typeof message === 'string') message = Buffer.from(message)
  if (!(Buffer.isBuffer(message))) throw new TypeError('message must be a buffer')
  method = method.toLowerCase()
  mode = mode.toUpperCase()

  const key = (method === 'rsa1024' || method === 'rsa2048') ? await this.getAsymmetricKeyPair(method, auth) : await this.getSymmetricKey(method, auth)
  let cipher
  let iv

  if (method === 'rsa1024') { // RSA 1024
    const cryptoKey = await subtle.importKey('spki', key.publicKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'])
    const ct = await subtle.encrypt({ name: 'RSA-OAEP' }, cryptoKey, message)
    return Buffer.from(ct)
  } else if (method === 'rsa2048') { // RSA 2048
    const cryptoKey = await subtle.importKey('spki', key.publicKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'])
    const ct = await subtle.encrypt({ name: 'RSA-OAEP' }, cryptoKey, message)
    return Buffer.from(ct)
  } else /* istanbul ignore if */ if (method === 'des') { // DES
    iv = (mode === 'ECB') ? Buffer.from('') : crypto.randomBytes(8)
    cipher = crypto.createCipheriv('DES-' + mode, key, iv)
  } else if (method === '3des') { // 3DES
    iv = (mode === 'ECB') ? Buffer.from('') : crypto.randomBytes(8)
    cipher = crypto.createCipheriv('DES-EDE3-' + mode, key, iv)
  } else if (method === 'aes128') { // AES 128
    iv = (mode === 'ECB') ? Buffer.from('') : crypto.randomBytes(16)
    cipher = crypto.createCipheriv('AES-128-' + mode, key, iv)
  } else if (method === 'aes192') { // AES 192
    iv = (mode === 'ECB') ? Buffer.from('') : crypto.randomBytes(16)
    cipher = crypto.createCipheriv('AES-192-' + mode, key, iv)
  } else { // AES 256
    iv = (mode === 'ECB') ? Buffer.from('') : crypto.randomBytes(16)
    cipher = crypto.createCipheriv('AES-256-' + mode, key, iv)
  }

  return Buffer.concat([iv, cipher.update(message), cipher.final()])
}
module.exports.encrypt = encrypt

/**
 * Decrypt a message with this key
 *
 * Note: DES is not supported on Node.js v18 and later
 *
 * @example
 * // setup multi-factor derived key
 * const key = await mfkdf.setup.key([ await mfkdf.setup.factors.password('password') ])
 *
 * // encrypt message using 3DES
 * const encrypted = await key.encrypt('hello world', '3des')
 *
 * // decrypt message using 3DES
 * const decrypted = await key.decrypt(encrypted, '3des')
 * decrypted.toString() // -> hello world
 *
 * @param {Buffer} message - The message to decrypt
 * @param {string} [method='aes256'] - Decryption method to use; des, 3des, aes128, aes192, or aes256
 * @param {string} [mode='CBC'] - Decryption mode to use; ECB, CFB, OFB, GCM, CTR, or CBC
 * @returns {Buffer} The decrypted message
 * @author Vivek Nair (https://nair.me) <[email protected]>
 * @since 0.10.0
 * @memberOf MFKDFDerivedKey
 * @async
 */
async function decrypt (message, method = 'aes256', mode = 'CBC') {
  if (!(Buffer.isBuffer(message))) throw new TypeError('message must be a buffer')
  method = method.toLowerCase()
  mode = mode.toUpperCase()

  const key = (method === 'rsa1024' || method === 'rsa2048') ? await this.getAsymmetricKeyPair(method) : await this.getSymmetricKey(method)
  let decipher
  let iv
  let ct

  if (method === 'rsa1024') { // RSA 1024
    const cryptoKey = await subtle.importKey('pkcs8', key.privateKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'])
    const ct = await subtle.decrypt({ name: 'RSA-OAEP' }, cryptoKey, message)
    return Buffer.from(ct)
  } else if (method === 'rsa2048') { // RSA 2048
    const cryptoKey = await subtle.importKey('pkcs8', key.privateKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt'])
    const ct = await subtle.decrypt({ name: 'RSA-OAEP' }, cryptoKey, message)
    return Buffer.from(ct)
  } else /* istanbul ignore if */ if (method === 'des') { // DES
    iv = (mode === 'ECB') ? '' : message.subarray(0, 8)
    ct = (mode === 'ECB') ? message : message.subarray(8)
    decipher = crypto.createDecipheriv('DES-' + mode, key, iv)
  } else if (method === '3des') { // 3DES
    iv = (mode === 'ECB') ? '' : message.subarray(0, 8)
    ct = (mode === 'ECB') ? message : message.subarray(8)
    decipher = crypto.createDecipheriv('DES-EDE3-' + mode, key, iv)
  } else if (method === 'aes128') { // AES 128
    iv = (mode === 'ECB') ? '' : message.subarray(0, 16)
    ct = (mode === 'ECB') ? message : message.subarray(16)
    decipher = crypto.createDecipheriv('AES-128-' + mode, key, iv)
  } else if (method === 'aes192') { // AES 192
    iv = (mode === 'ECB') ? '' : message.subarray(0, 16)
    ct = (mode === 'ECB') ? message : message.subarray(16)
    decipher = crypto.createDecipheriv('AES-192-' + mode, key, iv)
  } else { // AES 256
    iv = (mode === 'ECB') ? '' : message.subarray(0, 16)
    ct = (mode === 'ECB') ? message : message.subarray(16)
    decipher = crypto.createDecipheriv('AES-256-' + mode, key, iv)
  }

  // decipher.setAutoPadding(false);
  return Buffer.concat([decipher.update(ct), decipher.final()])
}
module.exports.decrypt = decrypt