Source: crypto.mjs

import { createDecipheriv, createCipheriv, randomBytes, scryptSync, pbkdf2Sync, getFips, createHmac } from 'crypto'

/**
 * @typedef {Object} EurekaCryptoProps
 * @property {string} password
 * @property {string} salt
 * @property {string} algorithm
 * @property {number} keyId
 * @property {'pbkdf2' | 'scrypt'} kdfFunction
 */

export class EurekaCrypto {
  #algorithm = null
  #key = null
  #isFips = false

  constructor (props) {
    if (!props.password || props.password === '') throw new Error('props.password must be provided in EurekaCrypto constructor!')
    if (!props.salt || props.salt === '') throw new Error('props.salt must be provided in EurekaCrypto constructor!')
    this.#isFips = getFips()

    if ((props.kdfFunction === 'scrypt' || props.kdfFunction == null) && !this.#isFips) {
      this.#key = this.#deriveScryptKey(props.password, props.salt)
    } else {
      this.#key = this.#derivePbkdf2Key(props.password, props.salt)
    }
    if ((props.algorithm === 'chacha20-poly1305' || props.algorithm == null) && !this.#isFips) {
      this.#algorithm = 'chacha20-poly1305'
    } else {
      this.#algorithm = 'aes-256-gcm'
    }
  }

  #deriveScryptKey (password, salt) {
    // take the shared key and run it through scrypt
    // use https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
    // to set params
    // default node params exceed owasp suggestions
    return scryptSync(password, salt, 32)
  }

  #derivePbkdf2Key (password, salt) {
    // take the shared key and run it through pbkdf2
    // use https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
    return pbkdf2Sync(password, salt, 310000, 32, 'sha256')
  }

  /**
   * Decrypt the cipher text with the provided context
   * @param {Buffer} ciphertext
   * @param {Buffer} context
   * @returns Buffer
   */
  async decrypt (ciphertext, context) {
    // ciphertext is the encrypted bits
    // context is what we expect the authTag to be,
    // cipher text should be of the following form
    // auth tag 16 bytes | iv 16 bytes | ciphertext n bytes
    // the auth tag will be set, we will set the expected authentication data
    // then proceed, if integrity has been altered GCM with authentication tag will
    // detect modification
    //
    // once decrypted whatever it is, is handed off to the caller for further vetting
    const AUTH_TAG_START = this.#getAuthTagStart()
    const AUTH_TAG_END = this.#getAuthTagEnd()
    const IV_START = this.#getIvStart()
    const IV_END = this.#getIvEnd()
    const CRYPTO_START = this.#getCryptoStart()
    const authTag = ciphertext.subarray(AUTH_TAG_START, AUTH_TAG_END)
    const iv = ciphertext.subarray(IV_START, IV_END)
    const crypt = ciphertext.subarray(CRYPTO_START)
    const decipher = createDecipheriv(this.getAlgorithm(), this.#key, iv)
    decipher.setAAD(context, {
      encoding: 'utf8'
    })
    decipher.setAuthTag(authTag)
    return Buffer.concat([decipher.update(crypt), decipher.final()])
  }

  /**
   * Verify the contents signature match
   * @param {Buffer} buffer
   * @param {Buffer} signature
   * @returns {boolean}
   */
  verify (buffer, signature) {
    // verify the signature
    // return true if valid
    // return false if invalid
    const hmac = createHmac('sha256', this.#key)
    hmac.update(buffer)
    const hmacDigest = hmac.digest()
    return hmacDigest.equals(signature)
  }

  /**
   *
   * @param {Buffer} buffer
   * @returns {Buffer} 16 byte signature
   */
  sign (buffer) {
    // sign the buffer
    // return the signature
    const hmac = createHmac('sha256', this.#key)
    hmac.update(buffer)
    return hmac.digest()
  }

  #getIvLength () {
    switch (this.#algorithm) {
      case 'chacha20-poly1305':
        return 12
      case 'aes-256-gcm':
        return 16
      default:
        throw new Error('Unsupported algorithm')
    }
  }

  #getAuthTagStart () {
    return 0
  }

  #getAuthTagEnd () {
    return 16
  }

  #getIvStart () {
    return 16
  }

  #getIvEnd () {
    switch (this.#algorithm) {
      case 'chacha20-poly1305':
        return 28
      case 'aes-256-gcm':
        return 32
      default:
        throw new Error('Unsupported algorithm')
    }
  }

  #getCryptoStart () {
    switch (this.#algorithm) {
      case 'chacha20-poly1305':
        return 28
      case 'aes-256-gcm':
        return 32
      default:
        throw new Error('Unsupported algorithm')
    }
  }

  getAlgorithm () {
    return this.#algorithm
  }

  getKeyId () {
    return 0
  }

  async encrypt (plaintext, context) {
    // take the plaintext/payload
    // create a random iv
    // use the context to generate a auth tag
    // encrypt it
    // return cipher text to caller for it to do what it needs to do
    const iv = randomBytes(this.#getIvLength())
    const cipher = createCipheriv(this.getAlgorithm(), this.#key, iv)
    cipher.setAAD(context, {
      encoding: 'utf8'
    })
    const rawCrypt = Buffer.concat([cipher.update(plaintext), cipher.final()])
    const tag = cipher.getAuthTag()
    return Buffer.concat([tag, iv, rawCrypt])
  }
}