diff --git a/src/index.ts b/src/index.ts index 1ac93eb..59ef3e9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,567 +1,706 @@ -import { hasProperty, isPlainObject } from '@metamask/utils'; - -export type DetailedEncryptionResult = { - vault: string; - exportedKeyString: string; -}; - -export type PBKDF2Params = { - iterations: number; -}; - -export type KeyDerivationOptions = { - algorithm: 'PBKDF2'; - params: PBKDF2Params; -}; - -export type EncryptionKey = { - key: CryptoKey; - derivationOptions: KeyDerivationOptions; -}; - -export type ExportedEncryptionKey = { - key: JsonWebKey; - derivationOptions: KeyDerivationOptions; -}; - -export type EncryptionResult = { - data: string; - iv: string; - salt?: string; - // old encryption results will not have this - keyMetadata?: KeyDerivationOptions; -}; - -export type DetailedDecryptResult = { - exportedKeyString: string; - vault: unknown; - salt: string; -}; - -const EXPORT_FORMAT = 'jwk'; -const DERIVED_KEY_FORMAT = 'AES-GCM'; -const STRING_ENCODING = 'utf-8'; -const OLD_DERIVATION_PARAMS: KeyDerivationOptions = { - algorithm: 'PBKDF2', - params: { - iterations: 10_000, - }, -}; -const DEFAULT_DERIVATION_PARAMS: KeyDerivationOptions = { - algorithm: 'PBKDF2', - params: { - iterations: 900_000, - }, -}; - -/** - * Encrypts a data object that can be any serializable value using - * a provided password. - * - * @param password - The password to use for encryption. - * @param dataObj - The data to encrypt. - * @param key - The CryptoKey to encrypt with. - * @param salt - The salt to use to encrypt. - * @param keyDerivationOptions - The options to use for key derivation. - * @returns The encrypted vault. - */ -export async function encrypt( - password: string, - dataObj: R, - key?: EncryptionKey | CryptoKey, - salt: string = generateSalt(), - keyDerivationOptions = DEFAULT_DERIVATION_PARAMS, -): Promise { - const cryptoKey = - key || (await keyFromPassword(password, salt, false, keyDerivationOptions)); - const payload = await encryptWithKey(cryptoKey, dataObj); - payload.salt = salt; - return JSON.stringify(payload); -} - -/** - * Encrypts a data object that can be any serializable value using - * a provided password. - * - * @param password - A password to use for encryption. - * @param dataObj - The data to encrypt. - * @param salt - The salt used to encrypt. - * @param keyDerivationOptions - The options to use for key derivation. - * @returns The vault and exported key string. - */ -export async function encryptWithDetail( - password: string, - dataObj: R, - salt = generateSalt(), - keyDerivationOptions = DEFAULT_DERIVATION_PARAMS, -): Promise { - const key = await keyFromPassword(password, salt, true, keyDerivationOptions); - const exportedKeyString = await exportKey(key); - const vault = await encrypt(password, dataObj, key, salt); - - return { - vault, - exportedKeyString, - }; -} - -/** - * Encrypts the provided serializable javascript object using the - * provided CryptoKey and returns an object containing the cypher text and - * the initialization vector used. - * - * @param encryptionKey - The CryptoKey to encrypt with. - * @param dataObj - A serializable JavaScript object to encrypt. - * @returns The encrypted data. - */ -export async function encryptWithKey( - encryptionKey: EncryptionKey | CryptoKey, - dataObj: R, -): Promise { - const data = JSON.stringify(dataObj); - const dataBuffer = Buffer.from(data, STRING_ENCODING); - const vector = globalThis.crypto.getRandomValues(new Uint8Array(16)); - const key = unwrapKey(encryptionKey); - - const buf = await globalThis.crypto.subtle.encrypt( - { - name: DERIVED_KEY_FORMAT, - iv: vector, - }, - key, - dataBuffer, - ); - - const buffer = new Uint8Array(buf); - const vectorStr = Buffer.from(vector).toString('base64'); - const vaultStr = Buffer.from(buffer).toString('base64'); - const encryptionResult: EncryptionResult = { - data: vaultStr, - iv: vectorStr, - }; - - if (isEncryptionKey(encryptionKey)) { - encryptionResult.keyMetadata = encryptionKey.derivationOptions; - } - - return encryptionResult; -} - -/** - * Given a password and a cypher text, decrypts the text and returns - * the resulting value. - * - * @param password - The password to decrypt with. - * @param text - The cypher text to decrypt. - * @param encryptionKey - The key to decrypt with. - * @returns The decrypted data. - */ -export async function decrypt( - password: string, - text: string, - encryptionKey?: EncryptionKey | CryptoKey, -): Promise { - const payload = JSON.parse(text); - const { salt, keyMetadata } = payload; - const cryptoKey = unwrapKey( - encryptionKey || - (await keyFromPassword(password, salt, false, keyMetadata)), - ); - - const result = await decryptWithKey(cryptoKey, payload); - return result; -} - -/** - * Given a password and a cypher text, decrypts the text and returns - * the resulting value, keyString, and salt. - * - * @param password - The password to decrypt with. - * @param text - The encrypted vault to decrypt. - * @returns The decrypted vault along with the salt and exported key. - */ -export async function decryptWithDetail( - password: string, - text: string, -): Promise { - const payload = JSON.parse(text); - const { salt, keyMetadata } = payload; - const key = await keyFromPassword(password, salt, true, keyMetadata); - const exportedKeyString = await exportKey(key); - const vault = await decrypt(password, text, key); - - return { - exportedKeyString, - vault, - salt, - }; -} - -/** - * Given a CryptoKey and an EncryptionResult object containing the initialization - * vector (iv) and data to decrypt, return the resulting decrypted value. - * - * @param encryptionKey - The CryptoKey to decrypt with. - * @param payload - The payload to decrypt, returned from an encryption method. - * @returns The decrypted data. - */ -export async function decryptWithKey( - encryptionKey: EncryptionKey | CryptoKey, - payload: EncryptionResult, -): Promise { - const encryptedData = Buffer.from(payload.data, 'base64'); - const vector = Buffer.from(payload.iv, 'base64'); - const key = unwrapKey(encryptionKey); - - let decryptedObj; - try { - const result = await crypto.subtle.decrypt( - { name: DERIVED_KEY_FORMAT, iv: vector }, - key, - encryptedData, - ); - - const decryptedData = new Uint8Array(result); - const decryptedStr = Buffer.from(decryptedData).toString(STRING_ENCODING); - decryptedObj = JSON.parse(decryptedStr); - } catch (e) { - throw new Error('Incorrect password'); - } - - return decryptedObj; -} - -/** - * Receives an exported CryptoKey string and creates a key. - * - * This function supports both JsonWebKey's and exported EncryptionKey's. - * It will return a CryptoKey for the former, and an EncryptionKey for the latter. - * - * @param keyString - The key string to import. - * @returns An EncryptionKey or a CryptoKey. - */ -export async function importKey( - keyString: string, -): Promise { - const exportedEncryptionKey = JSON.parse(keyString); - - if (isExportedEncryptionKey(exportedEncryptionKey)) { - return { - key: await globalThis.crypto.subtle.importKey( - EXPORT_FORMAT, - exportedEncryptionKey.key, - DERIVED_KEY_FORMAT, - true, - ['encrypt', 'decrypt'], - ), - derivationOptions: exportedEncryptionKey.derivationOptions, - }; - } - - return await globalThis.crypto.subtle.importKey( - EXPORT_FORMAT, - exportedEncryptionKey, - DERIVED_KEY_FORMAT, - true, - ['encrypt', 'decrypt'], - ); -} - -/** - * Exports a key string from a CryptoKey or from an - * EncryptionKey instance. - * - * @param encryptionKey - The CryptoKey or EncryptionKey to export. - * @returns A key string. - */ -export async function exportKey( - encryptionKey: CryptoKey | EncryptionKey, -): Promise { - if (isEncryptionKey(encryptionKey)) { - return JSON.stringify({ - key: await globalThis.crypto.subtle.exportKey( - EXPORT_FORMAT, - encryptionKey.key, - ), - derivationOptions: encryptionKey.derivationOptions, - }); - } - - return JSON.stringify( - await globalThis.crypto.subtle.exportKey(EXPORT_FORMAT, encryptionKey), - ); -} - -/** - * Generate a CryptoKey from a password and random salt. - * - * @param password - The password to use to generate key. - * @param salt - The salt string to use in key derivation. - * @param exportable - Whether or not the key should be exportable. - * @returns A CryptoKey for encryption and decryption. - */ -export async function keyFromPassword( - password: string, - salt: string, - exportable?: boolean, -): Promise; -/** - * Generate a CryptoKey from a password and random salt, specifying - * key derivation options. - * - * @param password - The password to use to generate key. - * @param salt - The salt string to use in key derivation. - * @param exportable - Whether or not the key should be exportable. - * @param opts - The options to use for key derivation. - * @returns An EncryptionKey for encryption and decryption. - */ -export async function keyFromPassword( - password: string, - salt: string, - exportable?: boolean, - opts?: KeyDerivationOptions, -): Promise; -// The overloads are already documented. -// eslint-disable-next-line jsdoc/require-jsdoc -export async function keyFromPassword( - password: string, - salt: string, - exportable = false, - opts: KeyDerivationOptions = OLD_DERIVATION_PARAMS, -): Promise { - const passBuffer = Buffer.from(password, STRING_ENCODING); - const saltBuffer = Buffer.from(salt, 'base64'); - - const key = await globalThis.crypto.subtle.importKey( - 'raw', - passBuffer, - { name: 'PBKDF2' }, - false, - ['deriveBits', 'deriveKey'], - ); - - const derivedKey = await globalThis.crypto.subtle.deriveKey( - { - name: 'PBKDF2', - salt: saltBuffer, - iterations: opts.params.iterations, - hash: 'SHA-256', - }, - key, - { name: DERIVED_KEY_FORMAT, length: 256 }, - exportable, - ['encrypt', 'decrypt'], - ); - - return opts - ? { - key: derivedKey, - derivationOptions: opts, - } - : derivedKey; -} - -/** - * Converts a hex string into a buffer. - * - * @param str - Hex encoded string. - * @returns The string ecoded as a byte array. - */ -export function serializeBufferFromStorage(str: string): Uint8Array { - const stripStr = str.slice(0, 2) === '0x' ? str.slice(2) : str; - const buf = new Uint8Array(stripStr.length / 2); - for (let i = 0; i < stripStr.length; i += 2) { - const seg = stripStr.substr(i, 2); - buf[i / 2] = parseInt(seg, 16); - } - return buf; -} - -/** - * Converts a buffer into a hex string ready for storage. - * - * @param buffer - Buffer to serialize. - * @returns A hex encoded string. - */ -export function serializeBufferForStorage(buffer: Uint8Array): string { - let result = '0x'; - buffer.forEach((value) => { - result += unprefixedHex(value); - }); - return result; -} - -/** - * Converts a number into hex value, and ensures proper leading 0 - * for single characters strings. - * - * @param num - The number to convert to string. - * @returns An unprefixed hex string. - */ -function unprefixedHex(num: number): string { - let hex = num.toString(16); - while (hex.length < 2) { - hex = `0${hex}`; - } - return hex; -} - -/** - * Generates a random string for use as a salt in CryptoKey generation. - * - * @param byteCount - The number of bytes to generate. - * @returns A randomly generated string. - */ -export function generateSalt(byteCount = 32): string { - const view = new Uint8Array(byteCount); - globalThis.crypto.getRandomValues(view); - // Uint8Array is a fixed length array and thus does not have methods like pop, etc - // so TypeScript complains about casting it to an array. Array.from() works here for - // getting the proper type, but it results in a functional difference. In order to - // cast, you have to first cast view to unknown then cast the unknown value to number[] - // TypeScript ftw: double opt in to write potentially type-mismatched code. - const b64encoded = btoa( - String.fromCharCode.apply(null, view as unknown as number[]), - ); - return b64encoded; -} - -/** - * Updates the provided vault, re-encrypting - * data with a safer algorithm if one is available. - * - * If the provided vault is already using the latest available encryption method, - * it is returned as is. - * - * @param vault - The vault to update. - * @param password - The password to use for encryption. - * @param targetDerivationParams - The options to use for key derivation. - * @returns A promise resolving to the updated vault. - */ -export async function updateVault( - vault: string, - password: string, - targetDerivationParams = DEFAULT_DERIVATION_PARAMS, -): Promise { - if (isVaultUpdated(vault, targetDerivationParams)) { - return vault; - } - - return encrypt( - password, - await decrypt(password, vault), - undefined, - undefined, - targetDerivationParams, - ); -} - -/** - * Updates the provided vault and exported key, re-encrypting - * data with a safer algorithm if one is available. - * - * If the provided vault is already using the latest available encryption method, - * it is returned as is. - * - * @param encryptionResult - The encrypted data to update. - * @param password - The password to use for encryption. - * @param targetDerivationParams - The options to use for key derivation. - * @returns A promise resolving to the updated encrypted data and exported key. - */ -export async function updateVaultWithDetail( - encryptionResult: DetailedEncryptionResult, - password: string, - targetDerivationParams = DEFAULT_DERIVATION_PARAMS, -): Promise { - if (isVaultUpdated(encryptionResult.vault, targetDerivationParams)) { - return encryptionResult; - } - - return encryptWithDetail( - password, - await decrypt(password, encryptionResult.vault), - undefined, - targetDerivationParams, - ); -} - -/** - * Checks if the provided key is an `EncryptionKey`. - * - * @param encryptionKey - The object to check. - * @returns Whether or not the key is an `EncryptionKey`. - */ -function isEncryptionKey( - encryptionKey: unknown, -): encryptionKey is EncryptionKey { - return ( - isPlainObject(encryptionKey) && - hasProperty(encryptionKey, 'key') && - hasProperty(encryptionKey, 'derivationOptions') && - encryptionKey.key instanceof CryptoKey && - isKeyDerivationOptions(encryptionKey.derivationOptions) - ); -} - -/** - * Checks if the provided object is a `KeyDerivationOptions`. - * - * @param derivationOptions - The object to check. - * @returns Whether or not the object is a `KeyDerivationOptions`. - */ -function isKeyDerivationOptions( - derivationOptions: unknown, -): derivationOptions is KeyDerivationOptions { - return ( - isPlainObject(derivationOptions) && - hasProperty(derivationOptions, 'algorithm') && - hasProperty(derivationOptions, 'params') - ); -} - -/** - * Checks if the provided key is an `ExportedEncryptionKey`. - * - * @param exportedKey - The object to check. - * @returns Whether or not the object is an `ExportedEncryptionKey`. - */ -function isExportedEncryptionKey( - exportedKey: unknown, -): exportedKey is ExportedEncryptionKey { - return ( - isPlainObject(exportedKey) && - hasProperty(exportedKey, 'key') && - hasProperty(exportedKey, 'derivationOptions') && - isKeyDerivationOptions(exportedKey.derivationOptions) - ); -} - -/** - * Returns the `CryptoKey` from the provided encryption key. - * If the provided key is a `CryptoKey`, it is returned as is. - * - * @param encryptionKey - The key to unwrap. - * @returns The `CryptoKey` from the provided encryption key. - */ -function unwrapKey(encryptionKey: EncryptionKey | CryptoKey): CryptoKey { - return isEncryptionKey(encryptionKey) ? encryptionKey.key : encryptionKey; -} - -/** - * Checks if the provided vault is an updated encryption format. - * - * @param vault - The vault to check. - * @param targetDerivationParams - The options to use for key derivation. - * @returns Whether or not the vault is an updated encryption format. - */ -export function isVaultUpdated( - vault: string, - targetDerivationParams = DEFAULT_DERIVATION_PARAMS, -): boolean { - const { keyMetadata } = JSON.parse(vault); - return ( - isKeyDerivationOptions(keyMetadata) && - keyMetadata.algorithm === targetDerivationParams.algorithm && - keyMetadata.params.iterations === targetDerivationParams.params.iterations - ); -} +import { hasProperty, isPlainObject } from '@metamask/utils'; + +export type DetailedEncryptionResult = { + vault: string; + exportedKeyString: string; +}; + +export type PBKDF2Params = { + iterations: number; +}; + +export type KeyDerivationOptions = { + algorithm: 'PBKDF2'; + params: PBKDF2Params; +}; + +export type EncryptionKey = { + key: CryptoKey; + derivationOptions: KeyDerivationOptions; +}; + +export type ExportedEncryptionKey = { + key: JsonWebKey; + derivationOptions: KeyDerivationOptions; +}; + +export type EncryptionResult = { + data: string; + iv: string; + salt?: string; + // old encryption results will not have this + keyMetadata?: KeyDerivationOptions; +}; + +export type DetailedDecryptResult = { + exportedKeyString: string; + vault: unknown; + salt: string; +}; + +const EXPORT_FORMAT = 'jwk'; +const DERIVED_KEY_FORMAT = 'AES-GCM'; +const STRING_ENCODING = 'utf-8'; +const OLD_DERIVATION_PARAMS: KeyDerivationOptions = { + algorithm: 'PBKDF2', + params: { + iterations: 10_000, + }, +}; +const DEFAULT_DERIVATION_PARAMS: KeyDerivationOptions = { + algorithm: 'PBKDF2', + params: { + iterations: 900_000, + }, +}; + +// Bounds applied to key-derivation options before they reach WebCrypto. +// A vault is attacker-controllable input: without a lower bound a crafted +// vault silently weakens the KDF, and without an upper bound a huge +// iteration count exhausts the CPU on every unlock attempt. The ceiling is +// a small multiple of the legitimate default (900,000): nothing genuine +// needs more, and higher values turn a substituted vault into a UI freeze. +const MIN_PBKDF2_ITERATIONS = 1_000; +const MAX_PBKDF2_ITERATIONS = 3_000_000; + +// Minimum accepted salt length in bytes (128 bits). Shorter salts do not +// provide enough entropy to guarantee per-vault KDF uniqueness. +const MIN_SALT_BYTE_LENGTH = 16; + +/** + * Validates key-derivation options, whether they come from an untrusted + * vault or from a direct API call. + * + * @param opts - The key derivation options to validate. + * @throws If the options are malformed or out of bounds. + */ +function validateKeyDerivationOptions(opts: KeyDerivationOptions): void { + if (!isKeyDerivationOptions(opts)) { + throw new Error('Invalid key derivation options'); + } + if (opts.algorithm !== 'PBKDF2') { + throw new Error(`Unsupported key derivation algorithm: ${opts.algorithm}`); + } + const { iterations } = opts.params; + if ( + !Number.isSafeInteger(iterations) || + iterations < MIN_PBKDF2_ITERATIONS || + iterations > MAX_PBKDF2_ITERATIONS + ) { + throw new Error( + `Invalid PBKDF2 iteration count: must be an integer between ${MIN_PBKDF2_ITERATIONS} and ${MAX_PBKDF2_ITERATIONS}`, + ); + } +} + +/** + * Parses an untrusted vault string and validates its required fields. + * A raw `JSON.parse` error or a vault missing `data`, `iv`, or `salt` + * surfaces here as a single typed "invalid vault format" error instead of + * an unhandled `SyntaxError`/`TypeError` deeper in the decrypt path. + * + * @param text - The vault string to parse. + * @returns The validated vault payload. + */ +function parseVault(text: string): EncryptionResult & { + keyMetadata?: KeyDerivationOptions; +} { + if (typeof text !== 'string') { + throw new Error('Invalid vault format: vault must be a string'); + } + let payload: unknown; + try { + payload = JSON.parse(text); + } catch (e) { + throw new Error('Invalid vault format: vault is not valid JSON'); + } + if ( + !isPlainObject(payload) || + typeof payload.data !== 'string' || + typeof payload.iv !== 'string' || + typeof payload.salt !== 'string' + ) { + throw new Error( + 'Invalid vault format: missing or invalid data, iv, or salt fields', + ); + } + return payload as EncryptionResult & { + keyMetadata?: KeyDerivationOptions; + }; +} + +/** + * Encrypts a data object that can be any serializable value using + * a provided password. + * + * @param password - The password to use for encryption. + * @param dataObj - The data to encrypt. + * @param key - The CryptoKey to encrypt with. + * @param salt - The salt to use to encrypt. + * @param keyDerivationOptions - The options to use for key derivation. + * @returns The encrypted vault. + */ +export async function encrypt( + password: string, + dataObj: R, + key?: EncryptionKey | CryptoKey, + salt: string = generateSalt(), + keyDerivationOptions = DEFAULT_DERIVATION_PARAMS, +): Promise { + const cryptoKey = + key || (await keyFromPassword(password, salt, false, keyDerivationOptions)); + const payload = await encryptWithKey(cryptoKey, dataObj); + payload.salt = salt; + return JSON.stringify(payload); +} + +/** + * Encrypts a data object that can be any serializable value using + * a provided password. + * + * @param password - A password to use for encryption. + * @param dataObj - The data to encrypt. + * @param salt - The salt used to encrypt. + * @param keyDerivationOptions - The options to use for key derivation. + * @returns The vault and exported key string. + */ +export async function encryptWithDetail( + password: string, + dataObj: R, + salt = generateSalt(), + keyDerivationOptions = DEFAULT_DERIVATION_PARAMS, +): Promise { + const key = await keyFromPassword(password, salt, true, keyDerivationOptions); + const exportedKeyString = await exportKey(key); + const vault = await encrypt(password, dataObj, key, salt); + + return { + vault, + exportedKeyString, + }; +} + +/** + * Encrypts the provided serializable javascript object using the + * provided CryptoKey and returns an object containing the cypher text and + * the initialization vector used. + * + * @param encryptionKey - The CryptoKey to encrypt with. + * @param dataObj - A serializable JavaScript object to encrypt. + * @returns The encrypted data. + */ +export async function encryptWithKey( + encryptionKey: EncryptionKey | CryptoKey, + dataObj: R, +): Promise { + const data = JSON.stringify(dataObj); + const dataBuffer = Buffer.from(data, STRING_ENCODING); + const vector = globalThis.crypto.getRandomValues(new Uint8Array(16)); + const key = unwrapKey(encryptionKey); + + const buf = await globalThis.crypto.subtle.encrypt( + { + name: DERIVED_KEY_FORMAT, + iv: vector, + }, + key, + dataBuffer, + ); + + const buffer = new Uint8Array(buf); + const vectorStr = Buffer.from(vector).toString('base64'); + const vaultStr = Buffer.from(buffer).toString('base64'); + const encryptionResult: EncryptionResult = { + data: vaultStr, + iv: vectorStr, + }; + + if (isEncryptionKey(encryptionKey)) { + encryptionResult.keyMetadata = encryptionKey.derivationOptions; + } + + return encryptionResult; +} + +/** + * Given a password and a cypher text, decrypts the text and returns + * the resulting value. + * + * @param password - The password to decrypt with. + * @param text - The cypher text to decrypt. + * @param encryptionKey - The key to decrypt with. + * @returns The decrypted data. + */ +export async function decrypt( + password: string, + text: string, + encryptionKey?: EncryptionKey | CryptoKey, +): Promise { + const payload = parseVault(text); + const { salt, keyMetadata } = payload; + const cryptoKey = unwrapKey( + encryptionKey || + (await keyFromPassword( + password, + salt, + false, + // A vault without keyMetadata is a legacy vault: use the legacy + // derivation params explicitly so these vaults still unlock, while + // the public `keyFromPassword` default stays at the modern params. + keyMetadata ?? OLD_DERIVATION_PARAMS, + )), + ); + + const result = await decryptWithKey(cryptoKey, payload); + return result; +} + +/** + * Given a password and a cypher text, decrypts the text and returns + * the resulting value, keyString, and salt. + * + * @param password - The password to decrypt with. + * @param text - The encrypted vault to decrypt. + * @returns The decrypted vault along with the salt and exported key. + */ +export async function decryptWithDetail( + password: string, + text: string, +): Promise { + const payload = parseVault(text); + const { salt, keyMetadata } = payload; + const key = await keyFromPassword( + password, + salt, + true, + // Legacy vaults carry no keyMetadata; derive with the legacy params so + // the exported key actually unlocks the vault. + keyMetadata ?? OLD_DERIVATION_PARAMS, + ); + const exportedKeyString = await exportKey(key); + const vault = await decrypt(password, text, key); + + return { + exportedKeyString, + vault, + salt, + }; +} + +/** + * Given a CryptoKey and an EncryptionResult object containing the initialization + * vector (iv) and data to decrypt, return the resulting decrypted value. + * + * @param encryptionKey - The CryptoKey to decrypt with. + * @param payload - The payload to decrypt, returned from an encryption method. + * @returns The decrypted data. + */ +export async function decryptWithKey( + encryptionKey: EncryptionKey | CryptoKey, + payload: EncryptionResult, +): Promise { + const encryptedData = Buffer.from(payload.data, 'base64'); + const vector = Buffer.from(payload.iv, 'base64'); + const key = unwrapKey(encryptionKey); + + let decryptedData: Uint8Array; + try { + // Only the WebCrypto decryption failure (wrong key or tampered + // ciphertext) is translated into a uniform "Incorrect password" error; + // keeping this indistinguishable avoids a decryption oracle. + const result = await crypto.subtle.decrypt( + { name: DERIVED_KEY_FORMAT, iv: vector }, + key, + encryptedData, + ); + decryptedData = new Uint8Array(result); + } catch (e) { + throw new Error('Incorrect password'); + } + + // Decryption succeeded, so the password was correct; failures below mean + // the plaintext itself is unusable and must surface as a distinct error + // rather than masquerading as a wrong password. + const decryptedStr = Buffer.from(decryptedData).toString(STRING_ENCODING); + try { + return JSON.parse(decryptedStr); + } catch (e) { + throw new Error('Corrupt vault: decrypted payload is not valid JSON'); + } +} + +/** + * Receives an exported CryptoKey string and creates a key. + * + * This function supports both JsonWebKey's and exported EncryptionKey's. + * It will return a CryptoKey for the former, and an EncryptionKey for the latter. + * + * @param keyString - The key string to import. + * @returns An EncryptionKey or a CryptoKey. + */ +export async function importKey( + keyString: string, +): Promise { + let exportedEncryptionKey: unknown; + try { + exportedEncryptionKey = JSON.parse(keyString); + } catch (e) { + throw new Error('Invalid key string: not valid JSON'); + } + + if (isExportedEncryptionKey(exportedEncryptionKey)) { + return { + key: await globalThis.crypto.subtle.importKey( + EXPORT_FORMAT, + exportedEncryptionKey.key, + DERIVED_KEY_FORMAT, + true, + ['encrypt', 'decrypt'], + ), + derivationOptions: exportedEncryptionKey.derivationOptions, + }; + } + + return await globalThis.crypto.subtle.importKey( + EXPORT_FORMAT, + exportedEncryptionKey, + DERIVED_KEY_FORMAT, + true, + ['encrypt', 'decrypt'], + ); +} + +/** + * Exports a key string from a CryptoKey or from an + * EncryptionKey instance. + * + * @param encryptionKey - The CryptoKey or EncryptionKey to export. + * @returns A key string. + */ +export async function exportKey( + encryptionKey: CryptoKey | EncryptionKey, +): Promise { + if (isEncryptionKey(encryptionKey)) { + return JSON.stringify({ + key: await globalThis.crypto.subtle.exportKey( + EXPORT_FORMAT, + encryptionKey.key, + ), + derivationOptions: encryptionKey.derivationOptions, + }); + } + + return JSON.stringify( + await globalThis.crypto.subtle.exportKey(EXPORT_FORMAT, encryptionKey), + ); +} + +/** + * Generate a CryptoKey from a password and random salt. + * + * @param password - The password to use to generate key. + * @param salt - The salt string to use in key derivation. + * @param exportable - Whether or not the key should be exportable. + * @returns A CryptoKey for encryption and decryption. + */ +export async function keyFromPassword( + password: string, + salt: string, + exportable?: boolean, +): Promise; +/** + * Generate a CryptoKey from a password and random salt, specifying + * key derivation options. + * + * @param password - The password to use to generate key. + * @param salt - The salt string to use in key derivation. + * @param exportable - Whether or not the key should be exportable. + * @param opts - The options to use for key derivation. + * @returns An EncryptionKey for encryption and decryption. + */ +export async function keyFromPassword( + password: string, + salt: string, + exportable?: boolean, + opts?: KeyDerivationOptions, +): Promise; +// The overloads are already documented. +// eslint-disable-next-line jsdoc/require-jsdoc +export async function keyFromPassword( + password: string, + salt: string, + exportable = false, + opts: KeyDerivationOptions = DEFAULT_DERIVATION_PARAMS, +): Promise { + // Legacy vaults (created before keyMetadata was recorded) are still + // handled by passing OLD_DERIVATION_PARAMS explicitly from the decrypt + // path; the public default is now the modern derivation params instead of + // the weak 10,000-iteration legacy ones. + validateKeyDerivationOptions(opts); + + const passBuffer = Buffer.from(password, STRING_ENCODING); + const saltBuffer = Buffer.from(salt, 'base64'); + if (saltBuffer.byteLength < MIN_SALT_BYTE_LENGTH) { + passBuffer.fill(0); + throw new Error( + `Invalid salt: must decode to at least ${MIN_SALT_BYTE_LENGTH} bytes`, + ); + } + + const key = await globalThis.crypto.subtle.importKey( + 'raw', + passBuffer, + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'], + ); + + const derivedKey = await globalThis.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: saltBuffer, + iterations: opts.params.iterations, + hash: 'SHA-256', + }, + key, + { name: DERIVED_KEY_FORMAT, length: 256 }, + exportable, + ['encrypt', 'decrypt'], + ); + + // Scrub the password bytes: the imported CryptoKey is non-extractable and + // lives inside WebCrypto, while this raw copy would otherwise linger in + // the JS heap until garbage collection. + passBuffer.fill(0); + + return opts + ? { + key: derivedKey, + derivationOptions: opts, + } + : derivedKey; +} + +/** + * Converts a hex string into a buffer. + * + * @param str - Hex encoded string. + * @returns The string ecoded as a byte array. + */ +export function serializeBufferFromStorage(str: string): Uint8Array { + const stripStr = str.slice(0, 2) === '0x' ? str.slice(2) : str; + const buf = new Uint8Array(stripStr.length / 2); + for (let i = 0; i < stripStr.length; i += 2) { + const seg = stripStr.substr(i, 2); + buf[i / 2] = parseInt(seg, 16); + } + return buf; +} + +/** + * Converts a buffer into a hex string ready for storage. + * + * @param buffer - Buffer to serialize. + * @returns A hex encoded string. + */ +export function serializeBufferForStorage(buffer: Uint8Array): string { + let result = '0x'; + buffer.forEach((value) => { + result += unprefixedHex(value); + }); + return result; +} + +/** + * Converts a number into hex value, and ensures proper leading 0 + * for single characters strings. + * + * @param num - The number to convert to string. + * @returns An unprefixed hex string. + */ +function unprefixedHex(num: number): string { + let hex = num.toString(16); + while (hex.length < 2) { + hex = `0${hex}`; + } + return hex; +} + +/** + * Generates a random string for use as a salt in CryptoKey generation. + * + * @param byteCount - The number of bytes to generate. + * @returns A randomly generated string. + */ +export function generateSalt(byteCount = 32): string { + const view = new Uint8Array(byteCount); + globalThis.crypto.getRandomValues(view); + // Uint8Array is a fixed length array and thus does not have methods like pop, etc + // so TypeScript complains about casting it to an array. Array.from() works here for + // getting the proper type, but it results in a functional difference. In order to + // cast, you have to first cast view to unknown then cast the unknown value to number[] + // TypeScript ftw: double opt in to write potentially type-mismatched code. + const b64encoded = btoa( + String.fromCharCode.apply(null, view as unknown as number[]), + ); + return b64encoded; +} + +/** + * Updates the provided vault, re-encrypting + * data with a safer algorithm if one is available. + * + * If the provided vault is already using the latest available encryption method, + * it is returned as is. + * + * @param vault - The vault to update. + * @param password - The password to use for encryption. + * @param targetDerivationParams - The options to use for key derivation. + * @returns A promise resolving to the updated vault. + */ +export async function updateVault( + vault: string, + password: string, + targetDerivationParams = DEFAULT_DERIVATION_PARAMS, +): Promise { + if (isVaultUpdated(vault, targetDerivationParams)) { + return vault; + } + + return encrypt( + password, + await decrypt(password, vault), + undefined, + undefined, + targetDerivationParams, + ); +} + +/** + * Updates the provided vault and exported key, re-encrypting + * data with a safer algorithm if one is available. + * + * If the provided vault is already using the latest available encryption method, + * it is returned as is. + * + * @param encryptionResult - The encrypted data to update. + * @param password - The password to use for encryption. + * @param targetDerivationParams - The options to use for key derivation. + * @returns A promise resolving to the updated encrypted data and exported key. + */ +export async function updateVaultWithDetail( + encryptionResult: DetailedEncryptionResult, + password: string, + targetDerivationParams = DEFAULT_DERIVATION_PARAMS, +): Promise { + if (isVaultUpdated(encryptionResult.vault, targetDerivationParams)) { + return encryptionResult; + } + + return encryptWithDetail( + password, + await decrypt(password, encryptionResult.vault), + undefined, + targetDerivationParams, + ); +} + +/** + * Checks if the provided key is an `EncryptionKey`. + * + * @param encryptionKey - The object to check. + * @returns Whether or not the key is an `EncryptionKey`. + */ +function isEncryptionKey( + encryptionKey: unknown, +): encryptionKey is EncryptionKey { + return ( + isPlainObject(encryptionKey) && + hasProperty(encryptionKey, 'key') && + hasProperty(encryptionKey, 'derivationOptions') && + encryptionKey.key instanceof CryptoKey && + isKeyDerivationOptions(encryptionKey.derivationOptions) + ); +} + +/** + * Checks if the provided object is a `KeyDerivationOptions`. + * + * @param derivationOptions - The object to check. + * @returns Whether or not the object is a `KeyDerivationOptions`. + */ +function isKeyDerivationOptions( + derivationOptions: unknown, +): derivationOptions is KeyDerivationOptions { + // Property presence alone is not enough: a crafted vault could carry + // `"params": null`, which passes the property check and then crashes on + // destructuring with a raw TypeError instead of the intended typed error. + if ( + !isPlainObject(derivationOptions) || + !hasProperty(derivationOptions, 'algorithm') || + !hasProperty(derivationOptions, 'params') || + !isPlainObject(derivationOptions.params) || + !hasProperty(derivationOptions.params, 'iterations') || + typeof derivationOptions.params.iterations !== 'number' + ) { + return false; + } + return true; +} + +/** + * Checks if the provided key is an `ExportedEncryptionKey`. + * + * @param exportedKey - The object to check. + * @returns Whether or not the object is an `ExportedEncryptionKey`. + */ +function isExportedEncryptionKey( + exportedKey: unknown, +): exportedKey is ExportedEncryptionKey { + return ( + isPlainObject(exportedKey) && + hasProperty(exportedKey, 'key') && + hasProperty(exportedKey, 'derivationOptions') && + isKeyDerivationOptions(exportedKey.derivationOptions) + ); +} + +/** + * Returns the `CryptoKey` from the provided encryption key. + * If the provided key is a `CryptoKey`, it is returned as is. + * + * @param encryptionKey - The key to unwrap. + * @returns The `CryptoKey` from the provided encryption key. + */ +function unwrapKey(encryptionKey: EncryptionKey | CryptoKey): CryptoKey { + return isEncryptionKey(encryptionKey) ? encryptionKey.key : encryptionKey; +} + +/** + * Checks if the provided vault is an updated encryption format. + * + * @param vault - The vault to check. + * @param targetDerivationParams - The options to use for key derivation. + * @returns Whether or not the vault is an updated encryption format. + */ +export function isVaultUpdated( + vault: string, + targetDerivationParams = DEFAULT_DERIVATION_PARAMS, +): boolean { + let payload: unknown; + try { + payload = JSON.parse(vault); + } catch (e) { + // A non-JSON vault cannot be in the updated format. + return false; + } + if (!isPlainObject(payload)) { + return false; + } + const { keyMetadata } = payload; + return ( + isKeyDerivationOptions(keyMetadata) && + keyMetadata.algorithm === targetDerivationParams.algorithm && + keyMetadata.params.iterations === targetDerivationParams.params.iterations + ); +} diff --git a/test/index.spec.ts b/test/index.spec.ts index 04b14fc..db9fda2 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,971 +1,971 @@ -import path from 'path'; -import { test, expect } from '@playwright/test'; - -import * as Encryptor from '../src'; - -declare global { - // Lint rule ignored to allow for declaration merging - // eslint-disable-next-line @typescript-eslint/consistent-type-definitions - interface Window { - encryptor: typeof Encryptor; - } -} - -const testPagePath = path.resolve(__dirname, 'index.html'); - -const OLD_SAMPLE_EXPORTED_KEY = - '{"alg":"A256GCM","ext":true,"k":"leW0IR00ACQp3SoWuITXQComCte7lwKLR9ztPlGkFeM","key_ops":["encrypt","decrypt"],"kty":"oct"}'; -const SAMPLE_EXPORTED_KEY = - '{"key":{"alg":"A256GCM","ext":true,"k":"leW0IR00ACQp3SoWuITXQComCte7lwKLR9ztPlGkFeM","key_ops":["encrypt","decrypt"],"kty":"oct"},"derivationOptions":{"algorithm":"PBKDF2","params":{"iterations":10000}}}'; - -test.beforeEach(async ({ page }) => { - await page.goto(`file://${testPagePath}`); -}); - -test('encryptor:serializeBufferForStorage', async ({ page }) => { - const output = await page.evaluate(() => { - const buffer = new Uint8Array(2); - buffer[0] = 16; - buffer[1] = 1; - return window.encryptor.serializeBufferForStorage(buffer); - }); - - const expected = '0x1001'; - expect(output).toBe(expected); -}); - -test('encryptor:serializeBufferFromStorage', async ({ page }) => { - const output = await page.evaluate(() => - window.encryptor.serializeBufferFromStorage('0x1001'), - ); - - expect(output[0]).toBe(16); - expect(output[1]).toBe(1); -}); - -test('encryptor:generateSalt generates 32 byte Base64-encoded string by default', async ({ - page, -}) => { - const salt = await page.evaluate(() => window.encryptor.generateSalt()); - - expect(salt.length).toBe(44); - const decodedSalt = await page.evaluate((args) => atob(args.salt), { salt }); - expect(decodedSalt.length).toBe(32); -}); - -test('encryptor:generateSalt generates 32 byte Base64-encoded string', async ({ - page, -}) => { - const salt = await page.evaluate(() => window.encryptor.generateSalt(32)); - - expect(salt.length).toBe(44); - const decodedSalt = await page.evaluate((args) => atob(args.salt), { salt }); - expect(decodedSalt.length).toBe(32); -}); - -test('encryptor:generateSalt generates 16 byte Base64-encoded string', async ({ - page, -}) => { - const salt = await page.evaluate(() => window.encryptor.generateSalt(16)); - - expect(salt.length).toBe(24); - const decodedSalt = await page.evaluate((args) => atob(args.salt), { salt }); - expect(decodedSalt.length).toBe(16); -}); - -test('encryptor:generateSalt generates 64 byte Base64-encoded string', async ({ - page, -}) => { - const salt = await page.evaluate(() => window.encryptor.generateSalt(64)); - - expect(salt.length).toBe(88); - const decodedSalt = await page.evaluate((args) => atob(args.salt), { salt }); - expect(decodedSalt.length).toBe(64); -}); - -test('encryptor:encrypt & decrypt', async ({ page }) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - - const encryptedString = await page.evaluate( - async (args) => await window.encryptor.encrypt(args.password, args.data), - { data, password }, - ); - expect(typeof encryptedString).toBe('string'); - - const decryptedObj = await page.evaluate( - async (args) => - await window.encryptor.decrypt(args.password, args.encryptedString), - { encryptedString, password }, - ); - expect(decryptedObj).toStrictEqual(data); -}); - -test('encryptor:encryptWithDetail returns vault', async ({ page }) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - - const encryptedDetail = await page.evaluate( - async (args) => - await window.encryptor.encryptWithDetail(args.password, args.data), - { data, password }, - ); - expect(typeof encryptedDetail.vault).toBe('string'); - expect(typeof encryptedDetail.exportedKeyString).toBe('string'); -}); - -test('encryptor:encrypt & decrypt with wrong password', async ({ page }) => { - const password = 'a sample passw0rd'; - const wrongPassword = 'a wrong password'; - const data = { foo: 'data to encrypt' }; - - const encryptedString = await page.evaluate( - async (args) => await window.encryptor.encrypt(args.password, args.data), - { data, password }, - ); - - await expect( - page.evaluate( - async (args) => - await window.encryptor.decrypt( - args.wrongPassword, - args.encryptedString, - ), - { encryptedString, wrongPassword }, - ), - ).rejects.toThrow('Incorrect password'); -}); - -/** - * This is the encrypted object `{ foo: 'data to encrypt' }`, which was - * encrypted using v2.0.3 of this library with the password - * `a sample passw0rd` and 10000 iterations. This should be left unmodified, - * as it's used to test that decrypting older encrypted data continues to work. - */ -const oldSampleEncryptedData: Encryptor.EncryptionResult = { - data: 'bfCvija6QfwqARmHsKT7ZR0GHi8yjz7iVEZodRVx3xI2yzFHwq7+B/U=', - iv: 'N9s46G5sp37A7wtf3vo/LA==', - salt: '+uzzUKmbAdwkjw8rILhJvZE9dOfz2ecF5Gtf7yNkyyE=', -}; - -/** - * This is the encrypted object `{ foo: 'data to encrypt' }`, which was - * encrypted using v5.0.0 of this library with the password - * `a sample passw0rd` and 900.000 iterations. This should be left unmodified, - * as it's used to test that decrypting older encrypted data continues to work. - */ -const sampleEncryptedData: Encryptor.EncryptionResult = { - data: 'WQbagUPb+XLvSR+U7sV9jzyS+5UZfVjBiWpmJjPOlJT93dJo9kltpls=', - iv: '7NsJ8mmL1DgC5LlsIyaIXA==', - salt: 'sysHvNRoWykN/JVUSpBwXhmp0llTMQabfY7zucEfAJg=', - keyMetadata: { - algorithm: 'PBKDF2', - params: { - iterations: 900000, - }, - }, -}; - -[sampleEncryptedData, oldSampleEncryptedData].forEach((testEncryptedData) => { - test.describe(`${ - testEncryptedData === oldSampleEncryptedData ? 'without' : 'with' - } key derivation function metadata`, () => { - test('encryptor:decrypt encrypted data', async ({ page }) => { - const password = 'a sample passw0rd'; - const expectedData = { foo: 'data to encrypt' }; - - const decryptedData = await page.evaluate( - async (args) => - await window.encryptor.decrypt( - args.password, - JSON.stringify(args.testEncryptedData), - ), - { testEncryptedData, password }, - ); - - expect(decryptedData).toStrictEqual(expectedData); - }); - - test('encryptor:decrypt encrypted data using wrong password', async ({ - page, - }) => { - const wrongPassword = 'a wrong password'; - - await expect( - page.evaluate( - async (args) => - await window.encryptor.decrypt( - args.wrongPassword, - JSON.stringify(args.testEncryptedData), - ), - { testEncryptedData, wrongPassword }, - ), - ).rejects.toThrow('Incorrect password'); - }); - - test('encryptor:decryptWithDetail returns same vault as decrypt', async ({ - page, - }) => { - const password = 'a sample passw0rd'; - - const decryptResult = await page.evaluate( - async (args) => { - return await window.encryptor.decrypt( - args.password, - JSON.stringify(args.testEncryptedData), - ); - }, - { password, testEncryptedData }, - ); - - const decryptWithDetailResult = await page.evaluate( - async (args) => { - return await window.encryptor.decryptWithDetail( - args.password, - JSON.stringify(args.testEncryptedData), - ); - }, - { password, testEncryptedData }, - ); - - expect(JSON.stringify(decryptResult)).toStrictEqual( - JSON.stringify(decryptWithDetailResult.vault), - ); - expect(Object.keys(decryptWithDetailResult).length).toBe(3); - expect(typeof decryptWithDetailResult.exportedKeyString).toStrictEqual( - 'string', - ); - }); - - test('encryptor:decrypt encrypted data using key', async ({ page }) => { - const password = 'a sample passw0rd'; - const expectedData = { foo: 'data to encrypt' }; - const { salt } = testEncryptedData; - - const decryptedData = await page.evaluate( - async (args) => { - const key = await window.encryptor.keyFromPassword( - args.password, - args.salt as string, - false, - args.testEncryptedData.keyMetadata, - ); - return await window.encryptor.decryptWithKey( - key, - args.testEncryptedData, - ); - }, - { testEncryptedData, password, salt }, - ); - - expect(decryptedData).toStrictEqual(expectedData); - }); - - test('encryptor:decrypt encrypted data using key derived from wrong password', async ({ - page, - }) => { - const wrongPassword = 'a wrong password'; - - await expect( - page.evaluate( - async (args) => { - const key = await window.encryptor.keyFromPassword( - args.wrongPassword, - args.salt as string, - false, - args.encryptedPayload.keyMetadata, - ); - return await window.encryptor.decryptWithKey( - key, - args.encryptedPayload, - ); - }, - { - encryptedPayload: testEncryptedData, - salt: testEncryptedData.salt, - wrongPassword, - }, - ), - ).rejects.toThrow('Incorrect password'); - }); - }); -}); - -test('encryptor:encrypt using key then decrypt', async ({ page }) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - const salt = await page.evaluate(() => window.encryptor.generateSalt()); - - const encryptedData = await page.evaluate( - async (args) => { - const key = await window.encryptor.keyFromPassword( - args.password, - args.salt, - ); - return await window.encryptor.encryptWithKey(key, args.data); - }, - { data, password, salt }, - ); - expect(Object.keys(encryptedData).sort()).toStrictEqual([ - 'data', - 'iv', - 'keyMetadata', - ]); - - const encryptedString = JSON.stringify( - Object.assign({}, encryptedData, { salt }), - ); - const decryptedData = await page.evaluate( - async (args) => - await window.encryptor.decrypt(args.password, args.encryptedString), - { encryptedString, password }, - ); - - expect(decryptedData).toStrictEqual(data); -}); - -test('encryptor:encrypt using key then decrypt using wrong password', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const wrongPassword = 'a wrong password'; - const data = { foo: 'data to encrypt' }; - const salt = await page.evaluate(() => window.encryptor.generateSalt()); - - const encryptedData = await page.evaluate( - async (args) => { - const key = await window.encryptor.keyFromPassword( - args.password, - args.salt, - ); - return await window.encryptor.encryptWithKey(key, args.data); - }, - { data, password, salt }, - ); - expect(Object.keys(encryptedData).sort()).toStrictEqual([ - 'data', - 'iv', - 'keyMetadata', - ]); - - const encryptedString = JSON.stringify( - Object.assign({}, encryptedData, { salt }), - ); - await expect( - page.evaluate( - async (args) => - await window.encryptor.decrypt( - args.wrongPassword, - args.encryptedString, - ), - { encryptedString, wrongPassword }, - ), - ).rejects.toThrow('Incorrect password'); -}); - -test('encryptor:encrypt then decrypt using key', async ({ page }) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - - const encryptedString = await page.evaluate( - async (args) => await window.encryptor.encrypt(args.password, args.data), - { data, password }, - ); - expect(typeof encryptedString).toBe('string'); - const encryptedData = JSON.parse(encryptedString); - const { salt } = encryptedData; - const encryptedPayload = { - data: encryptedData.data, - iv: encryptedData.iv, - keyMetadata: encryptedData.keyMetadata, - }; - - const decryptedData = await page.evaluate( - async (args) => { - const key = await window.encryptor.keyFromPassword( - args.password, - args.salt, - false, - args.encryptedPayload.keyMetadata, - ); - return await window.encryptor.decryptWithKey(key, args.encryptedPayload); - }, - { encryptedPayload, password, salt }, - ); - - expect(decryptedData).toStrictEqual(data); -}); - -test('encryptor:encrypt then decrypt using key derived from wrong password', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const wrongPassword = 'a wrong password'; - const data = { foo: 'data to encrypt' }; - - const encryptedString = await page.evaluate( - async (args) => await window.encryptor.encrypt(args.password, args.data), - { data, password }, - ); - expect(typeof encryptedString).toBe('string'); - const encryptedData = JSON.parse(encryptedString); - const { salt } = encryptedData; - const encryptedPayload = { - data: encryptedData.data, - iv: encryptedData.iv, - }; - - await expect( - page.evaluate( - async (args) => { - const key = await window.encryptor.keyFromPassword( - args.wrongPassword, - args.salt, - ); - return await window.encryptor.decryptWithKey( - key, - args.encryptedPayload, - ); - }, - { encryptedPayload, salt, wrongPassword }, - ), - ).rejects.toThrow('Incorrect password'); -}); - -test('encryptor:importKey generates valid CryptoKey using old key export format', async ({ - page, -}) => { - const isKey = await page.evaluate( - async (args) => { - const encryptionKey = await window.encryptor.importKey( - args.OLD_SAMPLE_EXPORTED_KEY, - ); - return encryptionKey instanceof CryptoKey; - }, - { OLD_SAMPLE_EXPORTED_KEY }, - ); - expect(isKey).toBe(true); -}); - -test('encryptor:importKey generates valid EncryptionKey using new key export format', async ({ - page, -}) => { - const isKey = await page.evaluate( - async (args) => { - const encryptionKey = await window.encryptor.importKey( - args.SAMPLE_EXPORTED_KEY, - ); - return ( - !(encryptionKey instanceof CryptoKey) && - encryptionKey.key instanceof CryptoKey && - encryptionKey.derivationOptions.algorithm === 'PBKDF2' && - encryptionKey.derivationOptions.params.iterations === 10000 - ); - }, - { SAMPLE_EXPORTED_KEY }, - ); - expect(isKey).toBe(true); -}); - -[OLD_SAMPLE_EXPORTED_KEY, SAMPLE_EXPORTED_KEY].forEach((testKey) => { - test.describe(`with the ${ - testKey === OLD_SAMPLE_EXPORTED_KEY ? 'old' : 'new' - } exported key format`, () => { - test('encryptor:exportKey generates valid CryptoKey string', async ({ - page, - }) => { - const keyString = await page.evaluate( - async (args) => { - const key = await window.encryptor.importKey(args.testKey); - return await window.encryptor.exportKey(key); - }, - { testKey }, - ); - expect(keyString).toStrictEqual(testKey); - }); - }); -}); - -test('encryptor:encryptWithDetail and decryptWithDetail provide same data', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - - const { vault } = await page.evaluate( - async (args) => - await window.encryptor.encryptWithDetail(args.password, args.data), - { data, password }, - ); - - const decryptedDetail = await page.evaluate( - async (args) => - await window.encryptor.decryptWithDetail(args.password, args.data), - { data: vault, password }, - ); - - expect(JSON.stringify(decryptedDetail.vault)).toStrictEqual( - JSON.stringify(data), - ); -}); - -test('encryptor:decryptWithKey provide same data when using exported key from encryptWithDetail', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - - const { vault, exportedKeyString } = await page.evaluate( - async (args) => - await window.encryptor.encryptWithDetail(args.password, args.data), - { data, password }, - ); - - // Use the exported key and vault to properly decrypt the data - const decryptWithKeyResult = await page.evaluate( - async (args) => { - const key = await window.encryptor.importKey(args.keyString); - return await window.encryptor.decryptWithKey(key, JSON.parse(args.data)); - }, - { data: vault, keyString: exportedKeyString }, - ); - - expect(JSON.stringify(decryptWithKeyResult)).toStrictEqual( - JSON.stringify(data), - ); -}); - -test('encryptor:decryptWithDetail works with password after encryption with key', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const startingData = { foo: 'data to encrypt' }; - - // Get an exported key to use - const { salt, exportedKeyString } = await page.evaluate( - async (args) => { - const usedSalt = window.encryptor.generateSalt(); - const { exportedKeyString: newKeyString } = - await window.encryptor.encryptWithDetail( - args.password, - args.data, - usedSalt, - ); - - return { - salt: usedSalt, - exportedKeyString: newKeyString, - }; - }, - { data: startingData, password }, - ); - - // Update the data, encrypt using key - const newData = { ...startingData, bar: 'more data' }; - const encryptWithKeyResult = await page.evaluate( - async (args) => { - const key = await window.encryptor.importKey(args.keyString); - return await window.encryptor.encryptWithKey(key, args.data); - }, - { data: newData, keyString: exportedKeyString }, - ); - - // Mock the encrypted object - const decryptable = { - ...encryptWithKeyResult, - salt, - }; - - // Prove that a vault created with key can be decrypted with password - const decryptedResult = await page.evaluate( - async (args) => - await window.encryptor.decryptWithDetail(args.password, args.data), - { password, data: JSON.stringify(decryptable) }, - ); - - expect(JSON.stringify(decryptedResult.vault)).toStrictEqual( - JSON.stringify(newData), - ); -}); - -test('encryptor:encryptWithKey works with decryptWithKey', async ({ page }) => { - const password = 'a sample passw0rd'; - const startingData = { foo: 'data to encrypt' }; - - // Get an exported key to use - const exportedKeyString = await page.evaluate( - async (args) => { - const { exportedKeyString: newKeyString } = - await window.encryptor.encryptWithDetail(args.password, args.data); - - return newKeyString; - }, - { data: startingData, password }, - ); - - // Update the data, encrypt using key - const newData = { ...startingData, bar: 'more data' }; - const encryptWithKeyResult = await page.evaluate( - async (args) => { - const key = await window.encryptor.importKey(args.keyString); - const result = await window.encryptor.encryptWithKey(key, args.data); - - return { - encryptWithKeyResult: result, - exportedKeyString: await window.encryptor.exportKey(key), - }; - }, - { data: newData, keyString: exportedKeyString }, - ); - - // Prove that a vault created with key can be decrypted with password - const decryptedResult = await page.evaluate( - async (args) => { - const key = await window.encryptor.importKey(args.exportedKeyString); - return await window.encryptor.decryptWithKey(key, args.data); - }, - { - exportedKeyString: encryptWithKeyResult.exportedKeyString, - data: encryptWithKeyResult.encryptWithKeyResult, - }, - ); - - expect(JSON.stringify(decryptedResult)).toStrictEqual( - JSON.stringify(newData), - ); -}); - -test('encryptor:keyFromPassword cannot be exported by default', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - const salt = await page.evaluate(() => window.encryptor.generateSalt()); - - const exportResult = await page.evaluate( - async (args) => { - const key = await window.encryptor.keyFromPassword( - args.password, - args.salt, - ); - - try { - const result = await window.encryptor.exportKey(key); - return result; - } catch (e) { - return 'error'; - } - }, - { data, password, salt }, - ); - - expect(exportResult).toStrictEqual('error'); -}); - -test('encryptor:decrypt old encrypted data and re-encrypt with password', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const expectedData = { foo: 'data to encrypt' }; - - const decryptedData = await page.evaluate( - async (args) => - await window.encryptor.decrypt( - args.password, - JSON.stringify(args.encryptedData), - ), - { encryptedData: oldSampleEncryptedData, password }, - ); - const encryptedData: Encryptor.EncryptionResult = JSON.parse( - await page.evaluate( - async (args) => await window.encryptor.encrypt(args.password, args.data), - { data: decryptedData, password }, - ), - ); - - expect(decryptedData).toStrictEqual(expectedData); - expect(encryptedData).toHaveProperty('keyMetadata'); - expect(encryptedData.keyMetadata).toStrictEqual({ - algorithm: 'PBKDF2', - params: { - iterations: 900000, - }, - }); -}); - -test('encryptor:encrypt with arbitrary key derivation options then decrypt', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - const salt = await page.evaluate(() => window.encryptor.generateSalt()); - - const encryptedString = await page.evaluate( - async (args) => - await window.encryptor.encrypt( - args.password, - args.data, - undefined, - args.salt, - { - algorithm: 'PBKDF2', - params: { - iterations: 100_000, - }, - }, - ), - { data, password, salt }, - ); - - const decryptedObj = await page.evaluate( - async (args) => - await window.encryptor.decrypt(args.password, args.encryptedString), - { encryptedString, password }, - ); - - expect(decryptedObj).toStrictEqual(data); -}); - -test('encryptor:encryptWithDetail with arbitrary key derivation options then decrypt', async ({ - page, -}) => { - const password = 'a sample passw0rd'; - const data = { foo: 'data to encrypt' }; - const salt = await page.evaluate(() => window.encryptor.generateSalt()); - - const { vault: encryptedString } = await page.evaluate( - async (args) => - await window.encryptor.encryptWithDetail( - args.password, - args.data, - args.salt, - { - algorithm: 'PBKDF2', - params: { - iterations: 100_000, - }, - }, - ), - { data, password, salt }, - ); - - const { vault: decryptedObj } = await page.evaluate( - async (args) => - await window.encryptor.decryptWithDetail( - args.password, - args.encryptedString, - ), - { encryptedString, password }, - ); - - expect(decryptedObj).toStrictEqual(data); -}); - -test.describe('encryptor:updateVault', async () => { - test.describe('with old vault format', async () => { - test('should return a vault encrypted with a key derived with new key derivation options', async ({ - page, - }) => { - const updatedVault = await page.evaluate( - async (args) => { - const vault = await window.encryptor.updateVault( - args.vault, - args.password, - ); - return JSON.parse(vault); - }, - { - vault: JSON.stringify(oldSampleEncryptedData), - password: 'a sample passw0rd', - }, - ); - - expect(updatedVault).toHaveProperty('keyMetadata'); - expect(updatedVault.keyMetadata).toStrictEqual( - sampleEncryptedData.keyMetadata, - ); - }); - - test('should return a vault that can be decrypted with the same password', async ({ - page, - }) => { - const password = 'a sample passw0rd'; - const updatedVault = await page.evaluate( - async (args) => window.encryptor.updateVault(args.vault, args.password), - { - vault: JSON.stringify(oldSampleEncryptedData), - password, - }, - ); - - const decryptedObj = await page.evaluate( - async (args) => - await window.encryptor.decrypt(args.password, args.encryptedString), - { - encryptedString: updatedVault, - password, - }, - ); - - expect(decryptedObj).toStrictEqual({ foo: 'data to encrypt' }); - }); - }); - - test.describe('with new vault format', async () => { - test('should return the same vault', async ({ page }) => { - const updatedVault = await page.evaluate( - async (args) => { - const vault = await window.encryptor.updateVault( - args.vault, - args.password, - ); - return JSON.parse(vault); - }, - { - vault: JSON.stringify(sampleEncryptedData), - password: 'a sample passw0rd', - }, - ); - - expect(updatedVault).toStrictEqual(sampleEncryptedData); - }); - }); -}); - -test.describe('encryptor:updateVaultWithDetail', async () => { - test.describe('with old vault format', async () => { - test('should return a vault encrypted with a key derived with new key derivation options', async ({ - page, - }) => { - const detailedVault: Encryptor.DetailedEncryptionResult = { - vault: JSON.stringify(oldSampleEncryptedData), - exportedKeyString: OLD_SAMPLE_EXPORTED_KEY, - }; - - const updatedVault = await page.evaluate( - async (args) => - window.encryptor.updateVaultWithDetail( - args.detailedVault, - args.password, - ), - { - detailedVault, - password: 'a sample passw0rd', - }, - ); - const vault = JSON.parse(updatedVault.vault); - - expect(vault).toHaveProperty('keyMetadata'); - expect(vault.keyMetadata).toStrictEqual(sampleEncryptedData.keyMetadata); - }); - - test('should return a vault that can be decrypted with the same password', async ({ - page, - }) => { - const password = 'a sample passw0rd'; - const detailedVault: Encryptor.DetailedEncryptionResult = { - vault: JSON.stringify(oldSampleEncryptedData), - exportedKeyString: OLD_SAMPLE_EXPORTED_KEY, - }; - const updatedVault = await page.evaluate( - async (args) => - window.encryptor.updateVaultWithDetail( - args.detailedVault, - args.password, - ), - { - detailedVault, - password, - }, - ); - - const decryptedObj = await page.evaluate( - async (args) => - await window.encryptor.decrypt(args.password, args.encryptedString), - { - encryptedString: updatedVault.vault, - password, - }, - ); - - expect(decryptedObj).toStrictEqual({ foo: 'data to encrypt' }); - }); - }); - - test.describe('with new vault format', async () => { - test('should return the same vault', async ({ page }) => { - const detailedVault: Encryptor.DetailedEncryptionResult = { - vault: JSON.stringify(sampleEncryptedData), - exportedKeyString: SAMPLE_EXPORTED_KEY, - }; - - const updatedVault = await page.evaluate( - async (args) => - window.encryptor.updateVaultWithDetail( - args.detailedVault, - args.password, - ), - { - detailedVault, - password: 'a sample passw0rd', - }, - ); - - expect(JSON.parse(updatedVault.vault)).toStrictEqual(sampleEncryptedData); - }); - }); -}); - -test.describe('encryptor:isVaultUpdated', async () => { - test('should return true with new vault format', async ({ page }) => { - const isVaultUpdated = await page.evaluate( - async (args) => window.encryptor.isVaultUpdated(args.vault), - { vault: JSON.stringify(sampleEncryptedData) }, - ); - - expect(isVaultUpdated).toBe(true); - }); - - test('should return false with old vault format', async ({ page }) => { - const isVaultUpdated = await page.evaluate( - async (args) => window.encryptor.isVaultUpdated(args.vault), - { vault: JSON.stringify(oldSampleEncryptedData) }, - ); - - expect(isVaultUpdated).toBe(false); - }); - - test('should return false if vault does not match target params', async ({ - page, - }) => { - const isVaultUpdated = await page.evaluate( - async (args) => - window.encryptor.isVaultUpdated(args.vault, { - algorithm: 'PBKDF2', - params: { - iterations: 100_000, - }, - }), - { vault: JSON.stringify(sampleEncryptedData) }, - ); - - expect(isVaultUpdated).toBe(false); - }); - - test('should return true if vault matches target params', async ({ - page, - }) => { - const isVaultUpdated = await page.evaluate( - async (args) => - window.encryptor.isVaultUpdated(args.vault, { - algorithm: 'PBKDF2', - params: { - iterations: 900_000, - }, - }), - { vault: JSON.stringify(sampleEncryptedData) }, - ); - - expect(isVaultUpdated).toBe(true); - }); -}); +import path from 'path'; +import { test, expect } from '@playwright/test'; + +import * as Encryptor from '../src'; + +declare global { + // Lint rule ignored to allow for declaration merging + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Window { + encryptor: typeof Encryptor; + } +} + +const testPagePath = path.resolve(__dirname, 'index.html'); + +const OLD_SAMPLE_EXPORTED_KEY = + '{"alg":"A256GCM","ext":true,"k":"leW0IR00ACQp3SoWuITXQComCte7lwKLR9ztPlGkFeM","key_ops":["encrypt","decrypt"],"kty":"oct"}'; +const SAMPLE_EXPORTED_KEY = + '{"key":{"alg":"A256GCM","ext":true,"k":"leW0IR00ACQp3SoWuITXQComCte7lwKLR9ztPlGkFeM","key_ops":["encrypt","decrypt"],"kty":"oct"},"derivationOptions":{"algorithm":"PBKDF2","params":{"iterations":10000}}}'; + +test.beforeEach(async ({ page }) => { + await page.goto(`file://${testPagePath}`); +}); + +test('encryptor:serializeBufferForStorage', async ({ page }) => { + const output = await page.evaluate(() => { + const buffer = new Uint8Array(2); + buffer[0] = 16; + buffer[1] = 1; + return window.encryptor.serializeBufferForStorage(buffer); + }); + + const expected = '0x1001'; + expect(output).toBe(expected); +}); + +test('encryptor:serializeBufferFromStorage', async ({ page }) => { + const output = await page.evaluate(() => + window.encryptor.serializeBufferFromStorage('0x1001'), + ); + + expect(output[0]).toBe(16); + expect(output[1]).toBe(1); +}); + +test('encryptor:generateSalt generates 32 byte Base64-encoded string by default', async ({ + page, +}) => { + const salt = await page.evaluate(() => window.encryptor.generateSalt()); + + expect(salt.length).toBe(44); + const decodedSalt = await page.evaluate((args) => atob(args.salt), { salt }); + expect(decodedSalt.length).toBe(32); +}); + +test('encryptor:generateSalt generates 32 byte Base64-encoded string', async ({ + page, +}) => { + const salt = await page.evaluate(() => window.encryptor.generateSalt(32)); + + expect(salt.length).toBe(44); + const decodedSalt = await page.evaluate((args) => atob(args.salt), { salt }); + expect(decodedSalt.length).toBe(32); +}); + +test('encryptor:generateSalt generates 16 byte Base64-encoded string', async ({ + page, +}) => { + const salt = await page.evaluate(() => window.encryptor.generateSalt(16)); + + expect(salt.length).toBe(24); + const decodedSalt = await page.evaluate((args) => atob(args.salt), { salt }); + expect(decodedSalt.length).toBe(16); +}); + +test('encryptor:generateSalt generates 64 byte Base64-encoded string', async ({ + page, +}) => { + const salt = await page.evaluate(() => window.encryptor.generateSalt(64)); + + expect(salt.length).toBe(88); + const decodedSalt = await page.evaluate((args) => atob(args.salt), { salt }); + expect(decodedSalt.length).toBe(64); +}); + +test('encryptor:encrypt & decrypt', async ({ page }) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + + const encryptedString = await page.evaluate( + async (args) => await window.encryptor.encrypt(args.password, args.data), + { data, password }, + ); + expect(typeof encryptedString).toBe('string'); + + const decryptedObj = await page.evaluate( + async (args) => + await window.encryptor.decrypt(args.password, args.encryptedString), + { encryptedString, password }, + ); + expect(decryptedObj).toStrictEqual(data); +}); + +test('encryptor:encryptWithDetail returns vault', async ({ page }) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + + const encryptedDetail = await page.evaluate( + async (args) => + await window.encryptor.encryptWithDetail(args.password, args.data), + { data, password }, + ); + expect(typeof encryptedDetail.vault).toBe('string'); + expect(typeof encryptedDetail.exportedKeyString).toBe('string'); +}); + +test('encryptor:encrypt & decrypt with wrong password', async ({ page }) => { + const password = 'a sample passw0rd'; + const wrongPassword = 'a wrong password'; + const data = { foo: 'data to encrypt' }; + + const encryptedString = await page.evaluate( + async (args) => await window.encryptor.encrypt(args.password, args.data), + { data, password }, + ); + + await expect( + page.evaluate( + async (args) => + await window.encryptor.decrypt( + args.wrongPassword, + args.encryptedString, + ), + { encryptedString, wrongPassword }, + ), + ).rejects.toThrow('Incorrect password'); +}); + +/** + * This is the encrypted object `{ foo: 'data to encrypt' }`, which was + * encrypted using v2.0.3 of this library with the password + * `a sample passw0rd` and 10000 iterations. This should be left unmodified, + * as it's used to test that decrypting older encrypted data continues to work. + */ +const oldSampleEncryptedData: Encryptor.EncryptionResult = { + data: 'bfCvija6QfwqARmHsKT7ZR0GHi8yjz7iVEZodRVx3xI2yzFHwq7+B/U=', + iv: 'N9s46G5sp37A7wtf3vo/LA==', + salt: '+uzzUKmbAdwkjw8rILhJvZE9dOfz2ecF5Gtf7yNkyyE=', +}; + +/** + * This is the encrypted object `{ foo: 'data to encrypt' }`, which was + * encrypted using v5.0.0 of this library with the password + * `a sample passw0rd` and 900.000 iterations. This should be left unmodified, + * as it's used to test that decrypting older encrypted data continues to work. + */ +const sampleEncryptedData: Encryptor.EncryptionResult = { + data: 'WQbagUPb+XLvSR+U7sV9jzyS+5UZfVjBiWpmJjPOlJT93dJo9kltpls=', + iv: '7NsJ8mmL1DgC5LlsIyaIXA==', + salt: 'sysHvNRoWykN/JVUSpBwXhmp0llTMQabfY7zucEfAJg=', + keyMetadata: { + algorithm: 'PBKDF2', + params: { + iterations: 900000, + }, + }, +}; + +[sampleEncryptedData, oldSampleEncryptedData].forEach((testEncryptedData) => { + test.describe(`${ + testEncryptedData === oldSampleEncryptedData ? 'without' : 'with' + } key derivation function metadata`, () => { + test('encryptor:decrypt encrypted data', async ({ page }) => { + const password = 'a sample passw0rd'; + const expectedData = { foo: 'data to encrypt' }; + + const decryptedData = await page.evaluate( + async (args) => + await window.encryptor.decrypt( + args.password, + JSON.stringify(args.testEncryptedData), + ), + { testEncryptedData, password }, + ); + + expect(decryptedData).toStrictEqual(expectedData); + }); + + test('encryptor:decrypt encrypted data using wrong password', async ({ + page, + }) => { + const wrongPassword = 'a wrong password'; + + await expect( + page.evaluate( + async (args) => + await window.encryptor.decrypt( + args.wrongPassword, + JSON.stringify(args.testEncryptedData), + ), + { testEncryptedData, wrongPassword }, + ), + ).rejects.toThrow('Incorrect password'); + }); + + test('encryptor:decryptWithDetail returns same vault as decrypt', async ({ + page, + }) => { + const password = 'a sample passw0rd'; + + const decryptResult = await page.evaluate( + async (args) => { + return await window.encryptor.decrypt( + args.password, + JSON.stringify(args.testEncryptedData), + ); + }, + { password, testEncryptedData }, + ); + + const decryptWithDetailResult = await page.evaluate( + async (args) => { + return await window.encryptor.decryptWithDetail( + args.password, + JSON.stringify(args.testEncryptedData), + ); + }, + { password, testEncryptedData }, + ); + + expect(JSON.stringify(decryptResult)).toStrictEqual( + JSON.stringify(decryptWithDetailResult.vault), + ); + expect(Object.keys(decryptWithDetailResult).length).toBe(3); + expect(typeof decryptWithDetailResult.exportedKeyString).toStrictEqual( + 'string', + ); + }); + + test('encryptor:decrypt encrypted data using key', async ({ page }) => { + const password = 'a sample passw0rd'; + const expectedData = { foo: 'data to encrypt' }; + const { salt } = testEncryptedData; + + const decryptedData = await page.evaluate( + async (args) => { + const key = await window.encryptor.keyFromPassword( + args.password, + args.salt as string, + false, + args.testEncryptedData.keyMetadata, + ); + return await window.encryptor.decryptWithKey( + key, + args.testEncryptedData, + ); + }, + { testEncryptedData, password, salt }, + ); + + expect(decryptedData).toStrictEqual(expectedData); + }); + + test('encryptor:decrypt encrypted data using key derived from wrong password', async ({ + page, + }) => { + const wrongPassword = 'a wrong password'; + + await expect( + page.evaluate( + async (args) => { + const key = await window.encryptor.keyFromPassword( + args.wrongPassword, + args.salt as string, + false, + args.encryptedPayload.keyMetadata, + ); + return await window.encryptor.decryptWithKey( + key, + args.encryptedPayload, + ); + }, + { + encryptedPayload: testEncryptedData, + salt: testEncryptedData.salt, + wrongPassword, + }, + ), + ).rejects.toThrow('Incorrect password'); + }); + }); +}); + +test('encryptor:encrypt using key then decrypt', async ({ page }) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + const salt = await page.evaluate(() => window.encryptor.generateSalt()); + + const encryptedData = await page.evaluate( + async (args) => { + const key = await window.encryptor.keyFromPassword( + args.password, + args.salt, + ); + return await window.encryptor.encryptWithKey(key, args.data); + }, + { data, password, salt }, + ); + expect(Object.keys(encryptedData).sort()).toStrictEqual([ + 'data', + 'iv', + 'keyMetadata', + ]); + + const encryptedString = JSON.stringify( + Object.assign({}, encryptedData, { salt }), + ); + const decryptedData = await page.evaluate( + async (args) => + await window.encryptor.decrypt(args.password, args.encryptedString), + { encryptedString, password }, + ); + + expect(decryptedData).toStrictEqual(data); +}); + +test('encryptor:encrypt using key then decrypt using wrong password', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const wrongPassword = 'a wrong password'; + const data = { foo: 'data to encrypt' }; + const salt = await page.evaluate(() => window.encryptor.generateSalt()); + + const encryptedData = await page.evaluate( + async (args) => { + const key = await window.encryptor.keyFromPassword( + args.password, + args.salt, + ); + return await window.encryptor.encryptWithKey(key, args.data); + }, + { data, password, salt }, + ); + expect(Object.keys(encryptedData).sort()).toStrictEqual([ + 'data', + 'iv', + 'keyMetadata', + ]); + + const encryptedString = JSON.stringify( + Object.assign({}, encryptedData, { salt }), + ); + await expect( + page.evaluate( + async (args) => + await window.encryptor.decrypt( + args.wrongPassword, + args.encryptedString, + ), + { encryptedString, wrongPassword }, + ), + ).rejects.toThrow('Incorrect password'); +}); + +test('encryptor:encrypt then decrypt using key', async ({ page }) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + + const encryptedString = await page.evaluate( + async (args) => await window.encryptor.encrypt(args.password, args.data), + { data, password }, + ); + expect(typeof encryptedString).toBe('string'); + const encryptedData = JSON.parse(encryptedString); + const { salt } = encryptedData; + const encryptedPayload = { + data: encryptedData.data, + iv: encryptedData.iv, + keyMetadata: encryptedData.keyMetadata, + }; + + const decryptedData = await page.evaluate( + async (args) => { + const key = await window.encryptor.keyFromPassword( + args.password, + args.salt, + false, + args.encryptedPayload.keyMetadata, + ); + return await window.encryptor.decryptWithKey(key, args.encryptedPayload); + }, + { encryptedPayload, password, salt }, + ); + + expect(decryptedData).toStrictEqual(data); +}); + +test('encryptor:encrypt then decrypt using key derived from wrong password', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const wrongPassword = 'a wrong password'; + const data = { foo: 'data to encrypt' }; + + const encryptedString = await page.evaluate( + async (args) => await window.encryptor.encrypt(args.password, args.data), + { data, password }, + ); + expect(typeof encryptedString).toBe('string'); + const encryptedData = JSON.parse(encryptedString); + const { salt } = encryptedData; + const encryptedPayload = { + data: encryptedData.data, + iv: encryptedData.iv, + }; + + await expect( + page.evaluate( + async (args) => { + const key = await window.encryptor.keyFromPassword( + args.wrongPassword, + args.salt, + ); + return await window.encryptor.decryptWithKey( + key, + args.encryptedPayload, + ); + }, + { encryptedPayload, salt, wrongPassword }, + ), + ).rejects.toThrow('Incorrect password'); +}); + +test('encryptor:importKey generates valid CryptoKey using old key export format', async ({ + page, +}) => { + const isKey = await page.evaluate( + async (args) => { + const encryptionKey = await window.encryptor.importKey( + args.OLD_SAMPLE_EXPORTED_KEY, + ); + return encryptionKey instanceof CryptoKey; + }, + { OLD_SAMPLE_EXPORTED_KEY }, + ); + expect(isKey).toBe(true); +}); + +test('encryptor:importKey generates valid EncryptionKey using new key export format', async ({ + page, +}) => { + const isKey = await page.evaluate( + async (args) => { + const encryptionKey = await window.encryptor.importKey( + args.SAMPLE_EXPORTED_KEY, + ); + return ( + !(encryptionKey instanceof CryptoKey) && + encryptionKey.key instanceof CryptoKey && + encryptionKey.derivationOptions.algorithm === 'PBKDF2' && + encryptionKey.derivationOptions.params.iterations === 10000 + ); + }, + { SAMPLE_EXPORTED_KEY }, + ); + expect(isKey).toBe(true); +}); + +[OLD_SAMPLE_EXPORTED_KEY, SAMPLE_EXPORTED_KEY].forEach((testKey) => { + test.describe(`with the ${ + testKey === OLD_SAMPLE_EXPORTED_KEY ? 'old' : 'new' + } exported key format`, () => { + test('encryptor:exportKey generates valid CryptoKey string', async ({ + page, + }) => { + const keyString = await page.evaluate( + async (args) => { + const key = await window.encryptor.importKey(args.testKey); + return await window.encryptor.exportKey(key); + }, + { testKey }, + ); + expect(keyString).toStrictEqual(testKey); + }); + }); +}); + +test('encryptor:encryptWithDetail and decryptWithDetail provide same data', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + + const { vault } = await page.evaluate( + async (args) => + await window.encryptor.encryptWithDetail(args.password, args.data), + { data, password }, + ); + + const decryptedDetail = await page.evaluate( + async (args) => + await window.encryptor.decryptWithDetail(args.password, args.data), + { data: vault, password }, + ); + + expect(JSON.stringify(decryptedDetail.vault)).toStrictEqual( + JSON.stringify(data), + ); +}); + +test('encryptor:decryptWithKey provide same data when using exported key from encryptWithDetail', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + + const { vault, exportedKeyString } = await page.evaluate( + async (args) => + await window.encryptor.encryptWithDetail(args.password, args.data), + { data, password }, + ); + + // Use the exported key and vault to properly decrypt the data + const decryptWithKeyResult = await page.evaluate( + async (args) => { + const key = await window.encryptor.importKey(args.keyString); + return await window.encryptor.decryptWithKey(key, JSON.parse(args.data)); + }, + { data: vault, keyString: exportedKeyString }, + ); + + expect(JSON.stringify(decryptWithKeyResult)).toStrictEqual( + JSON.stringify(data), + ); +}); + +test('encryptor:decryptWithDetail works with password after encryption with key', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const startingData = { foo: 'data to encrypt' }; + + // Get an exported key to use + const { salt, exportedKeyString } = await page.evaluate( + async (args) => { + const usedSalt = window.encryptor.generateSalt(); + const { exportedKeyString: newKeyString } = + await window.encryptor.encryptWithDetail( + args.password, + args.data, + usedSalt, + ); + + return { + salt: usedSalt, + exportedKeyString: newKeyString, + }; + }, + { data: startingData, password }, + ); + + // Update the data, encrypt using key + const newData = { ...startingData, bar: 'more data' }; + const encryptWithKeyResult = await page.evaluate( + async (args) => { + const key = await window.encryptor.importKey(args.keyString); + return await window.encryptor.encryptWithKey(key, args.data); + }, + { data: newData, keyString: exportedKeyString }, + ); + + // Mock the encrypted object + const decryptable = { + ...encryptWithKeyResult, + salt, + }; + + // Prove that a vault created with key can be decrypted with password + const decryptedResult = await page.evaluate( + async (args) => + await window.encryptor.decryptWithDetail(args.password, args.data), + { password, data: JSON.stringify(decryptable) }, + ); + + expect(JSON.stringify(decryptedResult.vault)).toStrictEqual( + JSON.stringify(newData), + ); +}); + +test('encryptor:encryptWithKey works with decryptWithKey', async ({ page }) => { + const password = 'a sample passw0rd'; + const startingData = { foo: 'data to encrypt' }; + + // Get an exported key to use + const exportedKeyString = await page.evaluate( + async (args) => { + const { exportedKeyString: newKeyString } = + await window.encryptor.encryptWithDetail(args.password, args.data); + + return newKeyString; + }, + { data: startingData, password }, + ); + + // Update the data, encrypt using key + const newData = { ...startingData, bar: 'more data' }; + const encryptWithKeyResult = await page.evaluate( + async (args) => { + const key = await window.encryptor.importKey(args.keyString); + const result = await window.encryptor.encryptWithKey(key, args.data); + + return { + encryptWithKeyResult: result, + exportedKeyString: await window.encryptor.exportKey(key), + }; + }, + { data: newData, keyString: exportedKeyString }, + ); + + // Prove that a vault created with key can be decrypted with password + const decryptedResult = await page.evaluate( + async (args) => { + const key = await window.encryptor.importKey(args.exportedKeyString); + return await window.encryptor.decryptWithKey(key, args.data); + }, + { + exportedKeyString: encryptWithKeyResult.exportedKeyString, + data: encryptWithKeyResult.encryptWithKeyResult, + }, + ); + + expect(JSON.stringify(decryptedResult)).toStrictEqual( + JSON.stringify(newData), + ); +}); + +test('encryptor:keyFromPassword cannot be exported by default', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + const salt = await page.evaluate(() => window.encryptor.generateSalt()); + + const exportResult = await page.evaluate( + async (args) => { + const key = await window.encryptor.keyFromPassword( + args.password, + args.salt, + ); + + try { + const result = await window.encryptor.exportKey(key); + return result; + } catch (e) { + return 'error'; + } + }, + { data, password, salt }, + ); + + expect(exportResult).toStrictEqual('error'); +}); + +test('encryptor:decrypt old encrypted data and re-encrypt with password', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const expectedData = { foo: 'data to encrypt' }; + + const decryptedData = await page.evaluate( + async (args) => + await window.encryptor.decrypt( + args.password, + JSON.stringify(args.encryptedData), + ), + { encryptedData: oldSampleEncryptedData, password }, + ); + const encryptedData: Encryptor.EncryptionResult = JSON.parse( + await page.evaluate( + async (args) => await window.encryptor.encrypt(args.password, args.data), + { data: decryptedData, password }, + ), + ); + + expect(decryptedData).toStrictEqual(expectedData); + expect(encryptedData).toHaveProperty('keyMetadata'); + expect(encryptedData.keyMetadata).toStrictEqual({ + algorithm: 'PBKDF2', + params: { + iterations: 900000, + }, + }); +}); + +test('encryptor:encrypt with arbitrary key derivation options then decrypt', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + const salt = await page.evaluate(() => window.encryptor.generateSalt()); + + const encryptedString = await page.evaluate( + async (args) => + await window.encryptor.encrypt( + args.password, + args.data, + undefined, + args.salt, + { + algorithm: 'PBKDF2', + params: { + iterations: 100_000, + }, + }, + ), + { data, password, salt }, + ); + + const decryptedObj = await page.evaluate( + async (args) => + await window.encryptor.decrypt(args.password, args.encryptedString), + { encryptedString, password }, + ); + + expect(decryptedObj).toStrictEqual(data); +}); + +test('encryptor:encryptWithDetail with arbitrary key derivation options then decrypt', async ({ + page, +}) => { + const password = 'a sample passw0rd'; + const data = { foo: 'data to encrypt' }; + const salt = await page.evaluate(() => window.encryptor.generateSalt()); + + const { vault: encryptedString } = await page.evaluate( + async (args) => + await window.encryptor.encryptWithDetail( + args.password, + args.data, + args.salt, + { + algorithm: 'PBKDF2', + params: { + iterations: 100_000, + }, + }, + ), + { data, password, salt }, + ); + + const { vault: decryptedObj } = await page.evaluate( + async (args) => + await window.encryptor.decryptWithDetail( + args.password, + args.encryptedString, + ), + { encryptedString, password }, + ); + + expect(decryptedObj).toStrictEqual(data); +}); + +test.describe('encryptor:updateVault', async () => { + test.describe('with old vault format', async () => { + test('should return a vault encrypted with a key derived with new key derivation options', async ({ + page, + }) => { + const updatedVault = await page.evaluate( + async (args) => { + const vault = await window.encryptor.updateVault( + args.vault, + args.password, + ); + return JSON.parse(vault); + }, + { + vault: JSON.stringify(oldSampleEncryptedData), + password: 'a sample passw0rd', + }, + ); + + expect(updatedVault).toHaveProperty('keyMetadata'); + expect(updatedVault.keyMetadata).toStrictEqual( + sampleEncryptedData.keyMetadata, + ); + }); + + test('should return a vault that can be decrypted with the same password', async ({ + page, + }) => { + const password = 'a sample passw0rd'; + const updatedVault = await page.evaluate( + async (args) => window.encryptor.updateVault(args.vault, args.password), + { + vault: JSON.stringify(oldSampleEncryptedData), + password, + }, + ); + + const decryptedObj = await page.evaluate( + async (args) => + await window.encryptor.decrypt(args.password, args.encryptedString), + { + encryptedString: updatedVault, + password, + }, + ); + + expect(decryptedObj).toStrictEqual({ foo: 'data to encrypt' }); + }); + }); + + test.describe('with new vault format', async () => { + test('should return the same vault', async ({ page }) => { + const updatedVault = await page.evaluate( + async (args) => { + const vault = await window.encryptor.updateVault( + args.vault, + args.password, + ); + return JSON.parse(vault); + }, + { + vault: JSON.stringify(sampleEncryptedData), + password: 'a sample passw0rd', + }, + ); + + expect(updatedVault).toStrictEqual(sampleEncryptedData); + }); + }); +}); + +test.describe('encryptor:updateVaultWithDetail', async () => { + test.describe('with old vault format', async () => { + test('should return a vault encrypted with a key derived with new key derivation options', async ({ + page, + }) => { + const detailedVault: Encryptor.DetailedEncryptionResult = { + vault: JSON.stringify(oldSampleEncryptedData), + exportedKeyString: OLD_SAMPLE_EXPORTED_KEY, + }; + + const updatedVault = await page.evaluate( + async (args) => + window.encryptor.updateVaultWithDetail( + args.detailedVault, + args.password, + ), + { + detailedVault, + password: 'a sample passw0rd', + }, + ); + const vault = JSON.parse(updatedVault.vault); + + expect(vault).toHaveProperty('keyMetadata'); + expect(vault.keyMetadata).toStrictEqual(sampleEncryptedData.keyMetadata); + }); + + test('should return a vault that can be decrypted with the same password', async ({ + page, + }) => { + const password = 'a sample passw0rd'; + const detailedVault: Encryptor.DetailedEncryptionResult = { + vault: JSON.stringify(oldSampleEncryptedData), + exportedKeyString: OLD_SAMPLE_EXPORTED_KEY, + }; + const updatedVault = await page.evaluate( + async (args) => + window.encryptor.updateVaultWithDetail( + args.detailedVault, + args.password, + ), + { + detailedVault, + password, + }, + ); + + const decryptedObj = await page.evaluate( + async (args) => + await window.encryptor.decrypt(args.password, args.encryptedString), + { + encryptedString: updatedVault.vault, + password, + }, + ); + + expect(decryptedObj).toStrictEqual({ foo: 'data to encrypt' }); + }); + }); + + test.describe('with new vault format', async () => { + test('should return the same vault', async ({ page }) => { + const detailedVault: Encryptor.DetailedEncryptionResult = { + vault: JSON.stringify(sampleEncryptedData), + exportedKeyString: SAMPLE_EXPORTED_KEY, + }; + + const updatedVault = await page.evaluate( + async (args) => + window.encryptor.updateVaultWithDetail( + args.detailedVault, + args.password, + ), + { + detailedVault, + password: 'a sample passw0rd', + }, + ); + + expect(JSON.parse(updatedVault.vault)).toStrictEqual(sampleEncryptedData); + }); + }); +}); + +test.describe('encryptor:isVaultUpdated', async () => { + test('should return true with new vault format', async ({ page }) => { + const isVaultUpdated = await page.evaluate( + async (args) => window.encryptor.isVaultUpdated(args.vault), + { vault: JSON.stringify(sampleEncryptedData) }, + ); + + expect(isVaultUpdated).toBe(true); + }); + + test('should return false with old vault format', async ({ page }) => { + const isVaultUpdated = await page.evaluate( + async (args) => window.encryptor.isVaultUpdated(args.vault), + { vault: JSON.stringify(oldSampleEncryptedData) }, + ); + + expect(isVaultUpdated).toBe(false); + }); + + test('should return false if vault does not match target params', async ({ + page, + }) => { + const isVaultUpdated = await page.evaluate( + async (args) => + window.encryptor.isVaultUpdated(args.vault, { + algorithm: 'PBKDF2', + params: { + iterations: 100_000, + }, + }), + { vault: JSON.stringify(sampleEncryptedData) }, + ); + + expect(isVaultUpdated).toBe(false); + }); + + test('should return true if vault matches target params', async ({ + page, + }) => { + const isVaultUpdated = await page.evaluate( + async (args) => + window.encryptor.isVaultUpdated(args.vault, { + algorithm: 'PBKDF2', + params: { + iterations: 900_000, + }, + }), + { vault: JSON.stringify(sampleEncryptedData) }, + ); + + expect(isVaultUpdated).toBe(true); + }); +});