safe_storage.js

import {
  base64ToUint8Array,
  concatUint8Arrays,
  stringToUint8Array,
  toUint8Array,
  uint8ArrayToBase64,
  uint8ArrayToString,
} from 'uint8array-extras'

const CIPHER_ALGORITHM = 'AES-CBC'
const CIPHER_KEY_LENGTH = 256 // bit
const CIPHER_BLOCK_SIZE = 16 // byte
const DIGEST_ALGORITHM = 'SHA-512'
const KEY_OBTENTION_ITERATIONS = 1000

/**
 * Electron-like encryption/decryption API for Node and browsers
 * <p>
 * The encryption/decryption API was designed with reference to
 * Electron's safeStorage API and chromium implementation.
 * The encryption algorithm used is <code>PBEWithHmacSHA512AndAES_256</code>,
 * which was inspired by Jasypt.
 *
 * @see [safeStorage | Electron]{@link https://www.electronjs.org/ja/docs/latest/api/safe-storage}
 * @see [chromium/components/os_crypt/sync/os_crypt_mac.mm]{@link https://github.com/chromium/chromium/blob/128.0.6597.1/components/os_crypt/sync/os_crypt_mac.mm}
 * @see [jasypt/jasypt/src/main/java/org/jasypt/util/text/AES256TextEncryptor.java at jasypt-1.9.3 · jasypt/jasypt]{@link https://github.com/jasypt/jasypt/blob/jasypt-1.9.3/jasypt/src/main/java/org/jasypt/util/text/AES256TextEncryptor.java}
 * @see [jasypt/jasypt/src/main/java/org/jasypt/encryption/pbe/StandardPBEByteEncryptor.java at jasypt-1.9.3 · jasypt/jasypt]{@link https://github.com/jasypt/jasypt/blob/jasypt-1.9.3/jasypt/src/main/java/org/jasypt/encryption/pbe/StandardPBEByteEncryptor.java}
 */
class SafeStorage {
  #password

  /**
   * Create a new <code>SafeStorage</code> with a password
   *
   * @param {string} password Password to encrypt/decrypt
   * @since 0.1.0
   */
  constructor(password) {
    this.#password = password
  }

  /**
   * Encrypt plain text with the password passed in the constructor
   *
   * @param {string} plainText Plain text to encrypt
   * @returns {Promise<string>} Promise that resolves with the encrypted text
   * @since 0.1.0
   */
  async encryptString(plainText) {
    const salt = new Uint8Array(CIPHER_BLOCK_SIZE)
    const iv = new Uint8Array(CIPHER_BLOCK_SIZE)
    globalThis.crypto.getRandomValues(salt)
    globalThis.crypto.getRandomValues(iv)

    const key = await this.#deriveKey(salt)
    const plainTextBytes = stringToUint8Array(plainText)
    let encryptedData
    try {
      encryptedData =
        await globalThis.crypto.subtle.encrypt(
          { name: CIPHER_ALGORITHM, iv },
          key,
          plainTextBytes
        )
    } catch (e) {
      throw new Error(
        'Encryption failed. SubtleCrypto#encrypt threw an exception.',
        { cause: e }
      )
    }

    // `encryptedData` is an ArrayBuffer so it needs to be converted.
    const concatenated =
      concatUint8Arrays([ salt, iv, toUint8Array(encryptedData) ])
    return uint8ArrayToBase64(concatenated)
  }

  /**
   * Decrypt encrypted text with the password passed in the constructor
   *
   * @param {string} encrypted Encrypted text to decrypt
   * @returns {Promise<string>} Promise that resolves with the decrypted text
   * @since 0.1.0
   */
  async decryptString(encrypted) {
    const buf = base64ToUint8Array(encrypted)
    if (buf.length < CIPHER_BLOCK_SIZE * 3) {
      throw new Error('The encrypted message is broken: the length is too short.')
    }

    const salt = buf.slice(0, CIPHER_BLOCK_SIZE)
    const iv = buf.slice(CIPHER_BLOCK_SIZE, CIPHER_BLOCK_SIZE * 2)
    const encryptedData = buf.slice(CIPHER_BLOCK_SIZE * 2)

    const key = await this.#deriveKey(salt)
    let decryptedData
    try {
      decryptedData =
        await globalThis.crypto.subtle.decrypt(
          { name: CIPHER_ALGORITHM, iv },
          key,
          encryptedData,
        )
    } catch (e) {
      throw new Error(
        'Decryption failed. SubtleCrypto#decrypt threw an exception. SafeStorage password may be incorrect.',
        { cause: e }
      )
    }

    return uint8ArrayToString(decryptedData)
  }

  async #deriveKey(salt) {
    const keyMaterial = await globalThis.crypto.subtle.importKey(
      'raw',
      stringToUint8Array(this.#password),
      'PBKDF2',
      false,
      ['deriveBits', 'deriveKey']
    )

    return globalThis.crypto.subtle.deriveKey(
      {
        name: 'PBKDF2',
        salt,
        iterations: KEY_OBTENTION_ITERATIONS,
        hash: DIGEST_ALGORITHM,
      },
      keyMaterial,
      { name: CIPHER_ALGORITHM, length: CIPHER_KEY_LENGTH },
      false,
      ['encrypt', 'decrypt'],
    )
  }
}

export { SafeStorage }