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 }