From 981f03bce20205aa3116bd57a98a1321c8664751 Mon Sep 17 00:00:00 2001 From: David Walsh Date: Mon, 31 Oct 2022 09:00:32 -0500 Subject: [PATCH 1/4] Provide extracted key with encryption --- src/index.ts | 129 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 116 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index 418b757..21e5d4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,29 +1,69 @@ +interface DetailedEncryptionResult { + vault: string; + exportedKeyString: string; +} + interface EncryptionResult { data: string; iv: string; salt?: string; } +interface DetailedDecryptResult { + exportedKeyString: string; + vault: unknown; + salt: string; +} + +const EXPORT_FORMAT = 'jwk'; +const DERIVED_KEY_FORMAT = 'AES-GCM'; +const STRING_ENCODING = 'utf-8'; + /** * Encrypts a data object that can be any serializable value using * a provided password. * * @param {string} password - password to use for encryption * @param {R} dataObj - data to encrypt + * @param {CryptoKey} key - a CryptoKey instance + * @param {string} salt - salt used to encrypt * @returns {Promise} cypher text */ export async function encrypt( password: string, dataObj: R, + key?: CryptoKey, + salt: string = generateSalt(), ): Promise { - const salt = generateSalt(); - - const passwordDerivedKey = await keyFromPassword(password, salt); - const payload = await encryptWithKey(passwordDerivedKey, dataObj); + const cryptoKey = key || (await keyFromPassword(password, salt)); + 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 {string} password - password to use for encryption + * @param {R} dataObj - data to encrypt + * @returns {Promise} object with vault and exportedKeyString + */ +export async function encryptWithDetail( + password: string, + dataObj: R, +): Promise { + const salt = generateSalt(); + const key = await keyFromPassword(password, salt); + 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 @@ -37,12 +77,12 @@ export async function encryptWithKey( dataObj: R, ): Promise { const data = JSON.stringify(dataObj); - const dataBuffer = Buffer.from(data, 'utf-8'); + const dataBuffer = Buffer.from(data, STRING_ENCODING); const vector = global.crypto.getRandomValues(new Uint8Array(16)); const buf = await global.crypto.subtle.encrypt( { - name: 'AES-GCM', + name: DERIVED_KEY_FORMAT, iv: vector, }, key, @@ -63,12 +103,45 @@ export async function encryptWithKey( * the resulting value * @param {string} password - password to decrypt with * @param {string} text - cypher text to decrypt + * @param {CryptoKey} key - a key to use for decrypting + * @returns {object} */ -export async function decrypt(password: string, text: string): Promise { +export async function decrypt( + password: string, + text: string, + key?: CryptoKey, +): Promise { + const payload = JSON.parse(text); + const { salt } = payload; + + const cryptoKey = key || (await keyFromPassword(password, salt)); + + 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 {string} password - password to decrypt with + * @param {string} text - cypher text to decrypt + * @returns {object} + */ +export async function decryptWithDetail( + password: string, + text: string, +): Promise { const payload = JSON.parse(text); const { salt } = payload; const key = await keyFromPassword(password, salt); - return await decryptWithKey(key, payload); + const exportedKeyString = await exportKey(key); + const vault = await decrypt(password, text, key); + + return { + exportedKeyString, + vault, + salt, + }; } /** @@ -87,13 +160,13 @@ export async function decryptWithKey( let decryptedObj; try { const result = await crypto.subtle.decrypt( - { name: 'AES-GCM', iv: vector }, + { name: DERIVED_KEY_FORMAT, iv: vector }, key, encryptedData, ); const decryptedData = new Uint8Array(result); - const decryptedStr = Buffer.from(decryptedData).toString('utf-8'); + const decryptedStr = Buffer.from(decryptedData).toString(STRING_ENCODING); decryptedObj = JSON.parse(decryptedStr); } catch (e) { throw new Error('Incorrect password'); @@ -102,6 +175,36 @@ export async function decryptWithKey( return decryptedObj; } +/** + * Receives an exported CryptoKey string and creates a key + * @param {string} keyString - keyString to import + * @returns {CryptoKey} + */ +export async function createKeyFromString( + keyString: string, +): Promise { + const key = await window.crypto.subtle.importKey( + EXPORT_FORMAT, + JSON.parse(keyString), + DERIVED_KEY_FORMAT, + true, + ['encrypt', 'decrypt'], + ); + + return key; +} + +/** + * Receives an exported CryptoKey string, creates a key, + * and decrypts cipher text with the reconstructed key + * @param {CryptoKey} key - key to export + * @returns {string} + */ +async function exportKey(key: CryptoKey): Promise { + const exportedKey = await window.crypto.subtle.exportKey(EXPORT_FORMAT, key); + return JSON.stringify(exportedKey); +} + /** * Generate a CryptoKey from a password and random salt * @param {string} password - The password to use to generate key @@ -111,7 +214,7 @@ export async function keyFromPassword( password: string, salt: string, ): Promise { - const passBuffer = Buffer.from(password, 'utf-8'); + const passBuffer = Buffer.from(password, STRING_ENCODING); const saltBuffer = Buffer.from(salt, 'base64'); const key = await global.crypto.subtle.importKey( @@ -130,8 +233,8 @@ export async function keyFromPassword( hash: 'SHA-256', }, key, - { name: 'AES-GCM', length: 256 }, - false, + { name: DERIVED_KEY_FORMAT, length: 256 }, + true, ['encrypt', 'decrypt'], ); From 0300a8643a3cf07801e196c63f4e33fdb94416af Mon Sep 17 00:00:00 2001 From: David Walsh Date: Mon, 31 Oct 2022 11:26:34 -0500 Subject: [PATCH 2/4] Add tests for new functionality --- src/index.ts | 5 ++- test/index.spec.ts | 99 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 21e5d4c..fa9c973 100644 --- a/src/index.ts +++ b/src/index.ts @@ -47,13 +47,14 @@ export async function encrypt( * * @param {string} password - password to use for encryption * @param {R} dataObj - data to encrypt + * @param {R} salt - salt used to encrypt * @returns {Promise} object with vault and exportedKeyString */ export async function encryptWithDetail( password: string, dataObj: R, + salt = generateSalt(), ): Promise { - const salt = generateSalt(); const key = await keyFromPassword(password, salt); const exportedKeyString = await exportKey(key); const vault = await encrypt(password, dataObj, key, salt); @@ -200,7 +201,7 @@ export async function createKeyFromString( * @param {CryptoKey} key - key to export * @returns {string} */ -async function exportKey(key: CryptoKey): Promise { +export async function exportKey(key: CryptoKey): Promise { const exportedKey = await window.crypto.subtle.exportKey(EXPORT_FORMAT, key); return JSON.stringify(exportedKey); } diff --git a/test/index.spec.ts b/test/index.spec.ts index d4f07e3..19cea51 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -11,6 +11,9 @@ declare global { const testPagePath = path.resolve(__dirname, 'index.html'); +const SAMPLE_EXPORTED_KEY = + '{"alg":"A256GCM","ext":true,"k":"leW0IR00ACQp3SoWuITXQComCte7lwKLR9ztPlGkFeM","key_ops":["encrypt","decrypt"],"kty":"oct"}'; + test.beforeEach(async ({ page }) => { await page.goto(`file://${testPagePath}`); }); @@ -94,6 +97,18 @@ test('encryptor:encrypt & decrypt', async ({ page }) => { 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'); +}); + test('encryptor:encrypt & decrypt with wrong password', async ({ page }) => { const password = 'a sample passw0rd'; const wrongPassword = 'a wrong password'; @@ -161,6 +176,37 @@ test('encryptor:decrypt encrypted data using wrong password', async ({ ).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.sampleEncryptedData), + ); + }, + { password, sampleEncryptedData }, + ); + + const decryptWithDetailResult = await page.evaluate( + async (args) => { + return await window.encryptor.decryptWithDetail( + args.password, + JSON.stringify(args.sampleEncryptedData), + ); + }, + { password, sampleEncryptedData }, + ); + + expect(JSON.stringify(decryptResult)).toStrictEqual( + JSON.stringify(decryptWithDetailResult.vault), + ); + expect(Object.keys(decryptWithDetailResult).length).toBe(3); +}); + test('encryptor:encrypt using key then decrypt', async ({ page }) => { const password = 'a sample passw0rd'; const data = { foo: 'data to encrypt' }; @@ -334,3 +380,56 @@ test('encryptor:decrypt encrypted data using key derived from wrong password', a ), ).rejects.toThrow('Incorrect password'); }); + +test('encryptor:createKeyFromString generates valid CryptoKey', async ({ + page, +}) => { + const isKey = await page.evaluate( + async (args) => { + const key = await window.encryptor.createKeyFromString( + args.SAMPLE_EXPORTED_KEY, + ); + return key instanceof CryptoKey; + }, + { 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.createKeyFromString( + args.SAMPLE_EXPORTED_KEY, + ); + return await window.encryptor.exportKey(key); + }, + { SAMPLE_EXPORTED_KEY }, + ); + expect(keyString).toStrictEqual(SAMPLE_EXPORTED_KEY); +}); + +test('encryptor:encryptWithDetail and decryptWithDetail provide same data ', 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 }, + ); + + const decryptedDetail = await page.evaluate( + async (args) => + await window.encryptor.decryptWithDetail(args.password, args.data), + { data: encryptedDetail.vault, password }, + ); + + expect(JSON.stringify(decryptedDetail.vault)).toStrictEqual( + JSON.stringify(data), + ); +}); From 7f1a1a882314804335cac0810b4b0f37a694fa46 Mon Sep 17 00:00:00 2001 From: David Walsh Date: Wed, 2 Nov 2022 20:49:53 -0500 Subject: [PATCH 3/4] Add requested tests --- test/index.spec.ts | 134 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 3 deletions(-) diff --git a/test/index.spec.ts b/test/index.spec.ts index 19cea51..08c1233 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -411,13 +411,13 @@ test('encryptor:exportKey generates valid CryptoKey string', async ({ expect(keyString).toStrictEqual(SAMPLE_EXPORTED_KEY); }); -test('encryptor:encryptWithDetail and decryptWithDetail provide same data ', async ({ +test('encryptor:encryptWithDetail and decryptWithDetail provide same data', async ({ page, }) => { const password = 'a sample passw0rd'; const data = { foo: 'data to encrypt' }; - const encryptedDetail = await page.evaluate( + const { vault } = await page.evaluate( async (args) => await window.encryptor.encryptWithDetail(args.password, args.data), { data, password }, @@ -426,10 +426,138 @@ test('encryptor:encryptWithDetail and decryptWithDetail provide same data ', asy const decryptedDetail = await page.evaluate( async (args) => await window.encryptor.decryptWithDetail(args.password, args.data), - { data: encryptedDetail.vault, password }, + { 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.createKeyFromString(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.createKeyFromString(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.createKeyFromString(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.createKeyFromString( + args.exportedKeyString, + ); + return await window.encryptor.decryptWithKey(key, args.data); + }, + { + exportedKeyString: encryptWithKeyResult.exportedKeyString, + data: encryptWithKeyResult.encryptWithKeyResult, + }, + ); + + expect(JSON.stringify(decryptedResult)).toStrictEqual( + JSON.stringify(newData), + ); +}); From ef1746e07b077c8d3ce94b35190aaac6f785a6da Mon Sep 17 00:00:00 2001 From: David Walsh Date: Thu, 3 Nov 2022 08:46:59 -0500 Subject: [PATCH 4/4] Rename createKeyFromString to importKey --- src/index.ts | 4 +--- test/index.spec.ts | 22 +++++++--------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/index.ts b/src/index.ts index fa9c973..b2e7e6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -181,9 +181,7 @@ export async function decryptWithKey( * @param {string} keyString - keyString to import * @returns {CryptoKey} */ -export async function createKeyFromString( - keyString: string, -): Promise { +export async function importKey(keyString: string): Promise { const key = await window.crypto.subtle.importKey( EXPORT_FORMAT, JSON.parse(keyString), diff --git a/test/index.spec.ts b/test/index.spec.ts index 08c1233..9cd1a8e 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -381,14 +381,10 @@ test('encryptor:decrypt encrypted data using key derived from wrong password', a ).rejects.toThrow('Incorrect password'); }); -test('encryptor:createKeyFromString generates valid CryptoKey', async ({ - page, -}) => { +test('encryptor:importKey generates valid CryptoKey', async ({ page }) => { const isKey = await page.evaluate( async (args) => { - const key = await window.encryptor.createKeyFromString( - args.SAMPLE_EXPORTED_KEY, - ); + const key = await window.encryptor.importKey(args.SAMPLE_EXPORTED_KEY); return key instanceof CryptoKey; }, { SAMPLE_EXPORTED_KEY }, @@ -401,9 +397,7 @@ test('encryptor:exportKey generates valid CryptoKey string', async ({ }) => { const keyString = await page.evaluate( async (args) => { - const key = await window.encryptor.createKeyFromString( - args.SAMPLE_EXPORTED_KEY, - ); + const key = await window.encryptor.importKey(args.SAMPLE_EXPORTED_KEY); return await window.encryptor.exportKey(key); }, { SAMPLE_EXPORTED_KEY }, @@ -449,7 +443,7 @@ test('encryptor:decryptWithKey provide same data when using exported key from en // Use the exported key and vault to properly decrypt the data const decryptWithKeyResult = await page.evaluate( async (args) => { - const key = await window.encryptor.createKeyFromString(args.keyString); + const key = await window.encryptor.importKey(args.keyString); return await window.encryptor.decryptWithKey(key, JSON.parse(args.data)); }, { data: vault, keyString: exportedKeyString }, @@ -489,7 +483,7 @@ test('encryptor:decryptWithDetail works with password after encryption with key' const newData = { ...startingData, bar: 'more data' }; const encryptWithKeyResult = await page.evaluate( async (args) => { - const key = await window.encryptor.createKeyFromString(args.keyString); + const key = await window.encryptor.importKey(args.keyString); return await window.encryptor.encryptWithKey(key, args.data); }, { data: newData, keyString: exportedKeyString }, @@ -532,7 +526,7 @@ test('encryptor:encryptWithKey works with decryptWithKey', async ({ page }) => { const newData = { ...startingData, bar: 'more data' }; const encryptWithKeyResult = await page.evaluate( async (args) => { - const key = await window.encryptor.createKeyFromString(args.keyString); + const key = await window.encryptor.importKey(args.keyString); const result = await window.encryptor.encryptWithKey(key, args.data); return { @@ -546,9 +540,7 @@ test('encryptor:encryptWithKey works with decryptWithKey', async ({ page }) => { // Prove that a vault created with key can be decrypted with password const decryptedResult = await page.evaluate( async (args) => { - const key = await window.encryptor.createKeyFromString( - args.exportedKeyString, - ); + const key = await window.encryptor.importKey(args.exportedKeyString); return await window.encryptor.decryptWithKey(key, args.data); }, {