diff --git a/package.json b/package.json index 4983937..7a7e3f3 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,9 @@ "prepack": "./scripts/prepack.sh", "test": "yarn buildTest && playwright test" }, + "dependencies": { + "@metamask/utils": "^8.2.0" + }, "devDependencies": { "@lavamoat/allow-scripts": "^2.0.3", "@metamask/auto-changelog": "^2.6.1", diff --git a/src/index.ts b/src/index.ts index 45fd310..5f38cc4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,35 @@ +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 = { @@ -18,6 +41,18 @@ export type DetailedDecryptResult = { 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 @@ -27,15 +62,18 @@ const STRING_ENCODING = 'utf-8'; * @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?: CryptoKey, + key?: EncryptionKey | CryptoKey, salt: string = generateSalt(), + keyDerivationOptions = DEFAULT_DERIVATION_PARAMS, ): Promise { - const cryptoKey = key || (await keyFromPassword(password, salt)); + const cryptoKey = + key || (await keyFromPassword(password, salt, false, keyDerivationOptions)); const payload = await encryptWithKey(cryptoKey, dataObj); payload.salt = salt; return JSON.stringify(payload); @@ -48,14 +86,16 @@ export async function encrypt( * @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); + const key = await keyFromPassword(password, salt, true, keyDerivationOptions); const exportedKeyString = await exportKey(key); const vault = await encrypt(password, dataObj, key, salt); @@ -70,17 +110,18 @@ export async function encryptWithDetail( * provided CryptoKey and returns an object containing the cypher text and * the initialization vector used. * - * @param key - The CryptoKey to encrypt with. + * @param encryptionKey - The CryptoKey to encrypt with. * @param dataObj - A serializable JavaScript object to encrypt. * @returns The encrypted data. */ export async function encryptWithKey( - key: CryptoKey, + encryptionKey: EncryptionKey | CryptoKey, dataObj: R, ): Promise { const data = JSON.stringify(dataObj); const dataBuffer = Buffer.from(data, STRING_ENCODING); const vector = global.crypto.getRandomValues(new Uint8Array(16)); + const key = unwrapKey(encryptionKey); const buf = await global.crypto.subtle.encrypt( { @@ -94,10 +135,16 @@ export async function encryptWithKey( const buffer = new Uint8Array(buf); const vectorStr = Buffer.from(vector).toString('base64'); const vaultStr = Buffer.from(buffer).toString('base64'); - return { + const encryptionResult: EncryptionResult = { data: vaultStr, iv: vectorStr, }; + + if (isEncryptionKey(encryptionKey)) { + encryptionResult.keyMetadata = encryptionKey.derivationOptions; + } + + return encryptionResult; } /** @@ -106,18 +153,20 @@ export async function encryptWithKey( * * @param password - The password to decrypt with. * @param text - The cypher text to decrypt. - * @param key - The key to decrypt with. + * @param encryptionKey - The key to decrypt with. * @returns The decrypted data. */ export async function decrypt( password: string, text: string, - key?: CryptoKey, + encryptionKey?: EncryptionKey | CryptoKey, ): Promise { const payload = JSON.parse(text); - const { salt } = payload; - - const cryptoKey = key || (await keyFromPassword(password, salt)); + const { salt, keyMetadata } = payload; + const cryptoKey = unwrapKey( + encryptionKey || + (await keyFromPassword(password, salt, false, keyMetadata)), + ); const result = await decryptWithKey(cryptoKey, payload); return result; @@ -136,8 +185,8 @@ export async function decryptWithDetail( text: string, ): Promise { const payload = JSON.parse(text); - const { salt } = payload; - const key = await keyFromPassword(password, salt, true); + const { salt, keyMetadata } = payload; + const key = await keyFromPassword(password, salt, true, keyMetadata); const exportedKeyString = await exportKey(key); const vault = await decrypt(password, text, key); @@ -152,16 +201,17 @@ export async function decryptWithDetail( * Given a CryptoKey and an EncryptionResult object containing the initialization * vector (iv) and data to decrypt, return the resulting decrypted value. * - * @param key - The CryptoKey to decrypt with. + * @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( - key: CryptoKey, + 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 { @@ -184,31 +234,62 @@ export async function decryptWithKey( /** * 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 A CryptoKey. + * @returns An EncryptionKey or a CryptoKey. */ -export async function importKey(keyString: string): Promise { - const key = await window.crypto.subtle.importKey( +export async function importKey( + keyString: string, +): Promise { + const exportedEncryptionKey = JSON.parse(keyString); + + if (isExportedEncryptionKey(exportedEncryptionKey)) { + return { + key: await window.crypto.subtle.importKey( + EXPORT_FORMAT, + exportedEncryptionKey.key, + DERIVED_KEY_FORMAT, + true, + ['encrypt', 'decrypt'], + ), + derivationOptions: exportedEncryptionKey.derivationOptions, + }; + } + + return await window.crypto.subtle.importKey( EXPORT_FORMAT, - JSON.parse(keyString), + exportedEncryptionKey, DERIVED_KEY_FORMAT, true, ['encrypt', 'decrypt'], ); - - return key; } /** - * Receives an exported CryptoKey string, creates a key, - * and decrypts cipher text with the reconstructed key. + * Exports a key string from a CryptoKey or from an + * EncryptionKey instance. * - * @param key - The CryptoKey to export. + * @param encryptionKey - The CryptoKey or EncryptionKey to export. * @returns A key string. */ -export async function exportKey(key: CryptoKey): Promise { - const exportedKey = await window.crypto.subtle.exportKey(EXPORT_FORMAT, key); - return JSON.stringify(exportedKey); +export async function exportKey( + encryptionKey: CryptoKey | EncryptionKey, +): Promise { + if (isEncryptionKey(encryptionKey)) { + return JSON.stringify({ + key: await window.crypto.subtle.exportKey( + EXPORT_FORMAT, + encryptionKey.key, + ), + derivationOptions: encryptionKey.derivationOptions, + }); + } + + return JSON.stringify( + await window.crypto.subtle.exportKey(EXPORT_FORMAT, encryptionKey), + ); } /** @@ -216,14 +297,38 @@ export async function exportKey(key: CryptoKey): Promise { * * @param password - The password to use to generate key. * @param salt - The salt string to use in key derivation. - * @param exportable - Should the derived key be exportable. + * @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, -): Promise { + opts: KeyDerivationOptions = OLD_DERIVATION_PARAMS, +): Promise { const passBuffer = Buffer.from(password, STRING_ENCODING); const saltBuffer = Buffer.from(salt, 'base64'); @@ -239,7 +344,7 @@ export async function keyFromPassword( { name: 'PBKDF2', salt: saltBuffer, - iterations: 10000, + iterations: opts.params.iterations, hash: 'SHA-256', }, key, @@ -248,7 +353,12 @@ export async function keyFromPassword( ['encrypt', 'decrypt'], ); - return derivedKey; + return opts + ? { + key: derivedKey, + derivationOptions: opts, + } + : derivedKey; } /** @@ -315,3 +425,128 @@ export function generateSalt(byteCount = 32): string { ); 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. + * @returns A promise resolving to the updated vault. + */ +export async function updateVault( + vault: string, + password: string, +): Promise { + if (isVaultUpdated(vault)) { + return vault; + } + + return encrypt(password, await decrypt(password, vault)); +} + +/** + * 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. + * @returns A promise resolving to the updated encrypted data and exported key. + */ +export async function updateVaultWithDetail( + encryptionResult: DetailedEncryptionResult, + password: string, +): Promise { + if (isVaultUpdated(encryptionResult.vault)) { + return encryptionResult; + } + + return encryptWithDetail( + password, + await decrypt(password, encryptionResult.vault), + ); +} + +/** + * 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. + * @returns Whether or not the vault is an updated encryption format. + */ +function isVaultUpdated(vault: string): boolean { + const { keyMetadata } = JSON.parse(vault); + return ( + isKeyDerivationOptions(keyMetadata) && + keyMetadata.algorithm === DEFAULT_DERIVATION_PARAMS.algorithm && + keyMetadata.params.iterations === + DEFAULT_DERIVATION_PARAMS.params.iterations + ); +} diff --git a/test/index.spec.ts b/test/index.spec.ts index db105c7..d7484c6 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -13,8 +13,10 @@ declare global { const testPagePath = path.resolve(__dirname, 'index.html'); -const SAMPLE_EXPORTED_KEY = +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}`); @@ -137,80 +139,156 @@ test('encryptor:encrypt & decrypt with wrong password', async ({ page }) => { /** * 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`. This should be left unmodified, as it's used to test - * that decrypting older encrypted data continues to work. + * `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 sampleEncryptedData = { +const oldSampleEncryptedData: Encryptor.EncryptionResult = { data: 'bfCvija6QfwqARmHsKT7ZR0GHi8yjz7iVEZodRVx3xI2yzFHwq7+B/U=', iv: 'N9s46G5sp37A7wtf3vo/LA==', salt: '+uzzUKmbAdwkjw8rILhJvZE9dOfz2ecF5Gtf7yNkyyE=', }; -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.sampleEncryptedData), - ), - { sampleEncryptedData, password }, - ); - - expect(decryptedData).toStrictEqual(expectedData); -}); +/** + * 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, + }, + }, +}; -test('encryptor:decrypt encrypted data using wrong password', async ({ - page, -}) => { - const wrongPassword = 'a wrong password'; +[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 }, + ); - await expect( - page.evaluate( - async (args) => - await window.encryptor.decrypt( - args.wrongPassword, - JSON.stringify(args.sampleEncryptedData), + 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 }, ), - { sampleEncryptedData, wrongPassword }, - ), - ).rejects.toThrow('Incorrect password'); -}); - -test('encryptor:decryptWithDetail returns same vault as decrypt', async ({ - page, -}) => { - const password = 'a sample passw0rd'; + ).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 decryptResult = await page.evaluate( - async (args) => { - return await window.encryptor.decrypt( - args.password, - JSON.stringify(args.sampleEncryptedData), + const decryptWithDetailResult = await page.evaluate( + async (args) => { + return await window.encryptor.decryptWithDetail( + args.password, + JSON.stringify(args.testEncryptedData), + ); + }, + { password, testEncryptedData }, ); - }, - { password, sampleEncryptedData }, - ); - const decryptWithDetailResult = await page.evaluate( - async (args) => { - return await window.encryptor.decryptWithDetail( - args.password, - JSON.stringify(args.sampleEncryptedData), + 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 }, ); - }, - { password, sampleEncryptedData }, - ); - expect(JSON.stringify(decryptResult)).toStrictEqual( - JSON.stringify(decryptWithDetailResult.vault), - ); - expect(Object.keys(decryptWithDetailResult).length).toBe(3); - expect(typeof decryptWithDetailResult.exportedKeyString).toStrictEqual( - 'string', - ); + 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 }) => { @@ -228,7 +306,11 @@ test('encryptor:encrypt using key then decrypt', async ({ page }) => { }, { data, password, salt }, ); - expect(Object.keys(encryptedData).sort()).toStrictEqual(['data', 'iv']); + expect(Object.keys(encryptedData).sort()).toStrictEqual([ + 'data', + 'iv', + 'keyMetadata', + ]); const encryptedString = JSON.stringify( Object.assign({}, encryptedData, { salt }), @@ -260,7 +342,11 @@ test('encryptor:encrypt using key then decrypt using wrong password', async ({ }, { data, password, salt }, ); - expect(Object.keys(encryptedData).sort()).toStrictEqual(['data', 'iv']); + expect(Object.keys(encryptedData).sort()).toStrictEqual([ + 'data', + 'iv', + 'keyMetadata', + ]); const encryptedString = JSON.stringify( Object.assign({}, encryptedData, { salt }), @@ -288,13 +374,19 @@ test('encryptor:encrypt then decrypt using key', async ({ page }) => { expect(typeof encryptedString).toBe('string'); const encryptedData = JSON.parse(encryptedString); const { salt } = encryptedData; - const encryptedPayload = { data: encryptedData.data, iv: encryptedData.iv }; + 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); }, @@ -318,7 +410,10 @@ test('encryptor:encrypt then decrypt using key derived from wrong password', asy expect(typeof encryptedString).toBe('string'); const encryptedData = JSON.parse(encryptedString); const { salt } = encryptedData; - const encryptedPayload = { data: encryptedData.data, iv: encryptedData.iv }; + const encryptedPayload = { + data: encryptedData.data, + iv: encryptedData.iv, + }; await expect( page.evaluate( @@ -337,78 +432,58 @@ test('encryptor:encrypt then decrypt using key derived from wrong password', asy ).rejects.toThrow('Incorrect password'); }); -test('encryptor:decrypt encrypted data using key', async ({ page }) => { - const password = 'a sample passw0rd'; - const expectedData = { foo: 'data to encrypt' }; - const encryptedPayload = { - data: sampleEncryptedData.data, - iv: sampleEncryptedData.iv, - }; - const { salt } = sampleEncryptedData; - - const decryptedData = await page.evaluate( +test('encryptor:importKey generates valid CryptoKey using old key export format', async ({ + page, +}) => { + const isKey = await page.evaluate( async (args) => { - const key = await window.encryptor.keyFromPassword( - args.password, - args.salt, + const encryptionKey = await window.encryptor.importKey( + args.OLD_SAMPLE_EXPORTED_KEY, ); - return await window.encryptor.decryptWithKey(key, args.encryptedPayload); + return encryptionKey instanceof CryptoKey; }, - { encryptedPayload, password, salt }, + { OLD_SAMPLE_EXPORTED_KEY }, ); - - expect(decryptedData).toStrictEqual(expectedData); + expect(isKey).toBe(true); }); -test('encryptor:decrypt encrypted data using key derived from wrong password', async ({ +test('encryptor:importKey generates valid EncryptionKey using new key export format', async ({ page, }) => { - const wrongPassword = 'a wrong password'; - const encryptedPayload = { - data: sampleEncryptedData.data, - iv: sampleEncryptedData.iv, - }; - const { salt } = sampleEncryptedData; - - 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', async ({ page }) => { const isKey = await page.evaluate( async (args) => { - const key = await window.encryptor.importKey(args.SAMPLE_EXPORTED_KEY); - return key instanceof CryptoKey; + 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); }); -test('encryptor:exportKey generates valid CryptoKey string', async ({ - page, -}) => { - const keyString = await page.evaluate( - async (args) => { - const key = await window.encryptor.importKey(args.SAMPLE_EXPORTED_KEY); - return await window.encryptor.exportKey(key); - }, - { SAMPLE_EXPORTED_KEY }, - ); - expect(keyString).toStrictEqual(SAMPLE_EXPORTED_KEY); +[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 ({ @@ -586,3 +661,257 @@ test('encryptor:keyFromPassword cannot be exported by default', async ({ 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); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 7602e27..6d6b120 100644 --- a/yarn.lock +++ b/yarn.lock @@ -33,6 +33,48 @@ __metadata: languageName: node linkType: hard +"@ethereumjs/common@npm:^3.2.0": + version: 3.2.0 + resolution: "@ethereumjs/common@npm:3.2.0" + dependencies: + "@ethereumjs/util": ^8.1.0 + crc-32: ^1.2.0 + checksum: cb9cc11f5c868cb577ba611cebf55046e509218bbb89b47ccce010776dafe8256d70f8f43fab238aec74cf71f62601cd5842bc03a83261200802de365732a14b + languageName: node + linkType: hard + +"@ethereumjs/rlp@npm:^4.0.1": + version: 4.0.1 + resolution: "@ethereumjs/rlp@npm:4.0.1" + bin: + rlp: bin/rlp + checksum: 30db19c78faa2b6ff27275ab767646929207bb207f903f09eb3e4c273ce2738b45f3c82169ddacd67468b4f063d8d96035f2bf36f02b6b7e4d928eefe2e3ecbc + languageName: node + linkType: hard + +"@ethereumjs/tx@npm:^4.2.0": + version: 4.2.0 + resolution: "@ethereumjs/tx@npm:4.2.0" + dependencies: + "@ethereumjs/common": ^3.2.0 + "@ethereumjs/rlp": ^4.0.1 + "@ethereumjs/util": ^8.1.0 + ethereum-cryptography: ^2.0.0 + checksum: 87a3f5f2452cfbf6712f8847525a80c213210ed453c211c793c5df801fe35ecef28bae17fadd222fcbdd94277478a47e52d2b916a90a6b30cda21f1e0cdaee42 + languageName: node + linkType: hard + +"@ethereumjs/util@npm:^8.1.0": + version: 8.1.0 + resolution: "@ethereumjs/util@npm:8.1.0" + dependencies: + "@ethereumjs/rlp": ^4.0.1 + ethereum-cryptography: ^2.0.0 + micro-ftch: ^0.3.1 + checksum: 9ae5dee8f12b0faf81cd83f06a41560e79b0ba96a48262771d897a510ecae605eb6d84f687da001ab8ccffd50f612ae50f988ef76e6312c752897f462f3ac08d + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -112,6 +154,7 @@ __metadata: "@metamask/eslint-config": ^10.0.0 "@metamask/eslint-config-nodejs": ^10.0.0 "@metamask/eslint-config-typescript": ^10.0.0 + "@metamask/utils": ^8.2.0 "@playwright/test": ^1.27.1 "@types/node": ^14.14.5 "@typescript-eslint/eslint-plugin": ^5.41.0 @@ -169,6 +212,45 @@ __metadata: languageName: node linkType: hard +"@metamask/utils@npm:^8.2.0": + version: 8.2.0 + resolution: "@metamask/utils@npm:8.2.0" + dependencies: + "@ethereumjs/tx": ^4.2.0 + "@noble/hashes": ^1.3.1 + "@scure/base": ^1.1.3 + "@types/debug": ^4.1.7 + debug: ^4.3.4 + pony-cause: ^2.1.10 + semver: ^7.5.4 + superstruct: ^1.0.3 + checksum: 1c70c0f9c375bfa3836c15d48990dbea1c3cadfd3dd69b4867667116c09c3bdeef70a0c7027f1cdea88a9913cb846dc94812ece91be7ec32e65a62e00281b04c + languageName: node + linkType: hard + +"@noble/curves@npm:1.1.0, @noble/curves@npm:~1.1.0": + version: 1.1.0 + resolution: "@noble/curves@npm:1.1.0" + dependencies: + "@noble/hashes": 1.3.1 + checksum: 2658cdd3f84f71079b4e3516c47559d22cf4b55c23ac8ee9d2b1f8e5b72916d9689e59820e0f9d9cb4a46a8423af5b56dc6bb7782405c88be06a015180508db5 + languageName: node + linkType: hard + +"@noble/hashes@npm:1.3.1": + version: 1.3.1 + resolution: "@noble/hashes@npm:1.3.1" + checksum: 7fdefc0f7a0c1ec27acc6ff88841793e3f93ec4ce6b8a6a12bfc0dd70ae6b7c4c82fe305fdfeda1735d5ad4a9eebe761e6693b3d355689c559e91242f4bc95b1 + languageName: node + linkType: hard + +"@noble/hashes@npm:^1.3.1, @noble/hashes@npm:~1.3.0, @noble/hashes@npm:~1.3.1": + version: 1.3.2 + resolution: "@noble/hashes@npm:1.3.2" + checksum: fe23536b436539d13f90e4b9be843cc63b1b17666a07634a2b1259dded6f490be3d050249e6af98076ea8f2ea0d56f578773c2197f2aa0eeaa5fba5bc18ba474 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -256,6 +338,34 @@ __metadata: languageName: node linkType: hard +"@scure/base@npm:^1.1.3, @scure/base@npm:~1.1.0": + version: 1.1.3 + resolution: "@scure/base@npm:1.1.3" + checksum: 1606ab8a4db898cb3a1ada16c15437c3bce4e25854fadc8eb03ae93cbbbac1ed90655af4b0be3da37e12056fef11c0374499f69b9e658c9e5b7b3e06353c630c + languageName: node + linkType: hard + +"@scure/bip32@npm:1.3.1": + version: 1.3.1 + resolution: "@scure/bip32@npm:1.3.1" + dependencies: + "@noble/curves": ~1.1.0 + "@noble/hashes": ~1.3.1 + "@scure/base": ~1.1.0 + checksum: 394d65f77a40651eba21a5096da0f4233c3b50d422864751d373fcf142eeedb94a1149f9ab1dbb078086dab2d0bc27e2b1afec8321bf22d4403c7df2fea5bfe2 + languageName: node + linkType: hard + +"@scure/bip39@npm:1.2.1": + version: 1.2.1 + resolution: "@scure/bip39@npm:1.2.1" + dependencies: + "@noble/hashes": ~1.3.0 + "@scure/base": ~1.1.0 + checksum: c5bd6f1328fdbeae2dcdd891825b1610225310e5e62a4942714db51066866e4f7bef242c7b06a1b9dcc8043a4a13412cf5c5df76d3b10aa9e36b82e9b6e3eeaa + languageName: node + linkType: hard + "@tootallnate/once@npm:2": version: 2.0.0 resolution: "@tootallnate/once@npm:2.0.0" @@ -263,6 +373,15 @@ __metadata: languageName: node linkType: hard +"@types/debug@npm:^4.1.7": + version: 4.1.11 + resolution: "@types/debug@npm:4.1.11" + dependencies: + "@types/ms": "*" + checksum: 6f666691f4706c4c1bbb3023026bc3acff7c98f181cbd4bd12bfc56caa9b4cd084e996358ab7b7748e7d9a59a00cdd15f0c6d4b60ff5a085eac98fa71f04784e + languageName: node + linkType: hard + "@types/glob@npm:^7.1.1": version: 7.2.0 resolution: "@types/glob@npm:7.2.0" @@ -294,6 +413,13 @@ __metadata: languageName: node linkType: hard +"@types/ms@npm:*": + version: 0.7.34 + resolution: "@types/ms@npm:0.7.34" + checksum: f38d36e7b6edecd9badc9cf50474159e9da5fa6965a75186cceaf883278611b9df6669dc3a3cc122b7938d317b68a9e3d573d316fcb35d1be47ec9e468c6bd8a + languageName: node + linkType: hard + "@types/node@npm:*": version: 18.11.9 resolution: "@types/node@npm:18.11.9" @@ -1196,6 +1322,15 @@ __metadata: languageName: node linkType: hard +"crc-32@npm:^1.2.0": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: ad2d0ad0cbd465b75dcaeeff0600f8195b686816ab5f3ba4c6e052a07f728c3e70df2e3ca9fd3d4484dc4ba70586e161ca5a2334ec8bf5a41bf022a6103ff243 + languageName: node + linkType: hard + "create-ecdh@npm:^4.0.0": version: 4.0.4 resolution: "create-ecdh@npm:4.0.4" @@ -1863,6 +1998,18 @@ __metadata: languageName: node linkType: hard +"ethereum-cryptography@npm:^2.0.0": + version: 2.1.2 + resolution: "ethereum-cryptography@npm:2.1.2" + dependencies: + "@noble/curves": 1.1.0 + "@noble/hashes": 1.3.1 + "@scure/bip32": 1.3.1 + "@scure/bip39": 1.2.1 + checksum: 2e8f7b8cc90232ae838ab6a8167708e8362621404d26e79b5d9e762c7b53d699f7520aff358d9254de658fcd54d2d0af168ff909943259ed27dc4cef2736410c + languageName: node + linkType: hard + "events@npm:^3.0.0": version: 3.3.0 resolution: "events@npm:3.3.0" @@ -3030,6 +3177,13 @@ __metadata: languageName: node linkType: hard +"micro-ftch@npm:^0.3.1": + version: 0.3.1 + resolution: "micro-ftch@npm:0.3.1" + checksum: 0e496547253a36e98a83fb00c628c53c3fb540fa5aaeaf718438873785afd193244988c09d219bb1802984ff227d04938d9571ef90fe82b48bd282262586aaff + languageName: node + linkType: hard + "micromatch@npm:^4.0.4": version: 4.0.5 resolution: "micromatch@npm:4.0.5" @@ -3627,6 +3781,13 @@ __metadata: languageName: node linkType: hard +"pony-cause@npm:^2.1.10": + version: 2.1.10 + resolution: "pony-cause@npm:2.1.10" + checksum: 8b61378f213e61056312dc274a1c79980154e9d864f6ad86e0c8b91a50d3ce900d430995ee24147c9f3caa440dfe7d51c274b488d7f033b65b206522536d7217 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -4013,6 +4174,17 @@ __metadata: languageName: node linkType: hard +"semver@npm:^7.5.4": + version: 7.5.4 + resolution: "semver@npm:7.5.4" + dependencies: + lru-cache: ^6.0.0 + bin: + semver: bin/semver.js + checksum: 12d8ad952fa353b0995bf180cdac205a4068b759a140e5d3c608317098b3575ac2f1e09182206bf2eb26120e1c0ed8fb92c48c592f6099680de56bb071423ca3 + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0, set-blocking@npm:~2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -4360,6 +4532,13 @@ __metadata: languageName: node linkType: hard +"superstruct@npm:^1.0.3": + version: 1.0.3 + resolution: "superstruct@npm:1.0.3" + checksum: 761790bb111e6e21ddd608299c252f3be35df543263a7ebbc004e840d01fcf8046794c274bcb351bdf3eae4600f79d317d085cdbb19ca05803a4361840cc9bb1 + languageName: node + linkType: hard + "supports-color@npm:^7.1.0": version: 7.2.0 resolution: "supports-color@npm:7.2.0"