Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide exported key with encryption #29

Merged
merged 4 commits into from
Nov 3, 2022
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 117 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,70 @@
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<string>} cypher text
*/
export async function encrypt<R>(
password: string,
dataObj: R,
key?: CryptoKey,
salt: string = generateSalt(),
): Promise<string> {
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
* @param {R} salt - salt used to encrypt
* @returns {Promise<DetailedEncryptionResult>} object with vault and exportedKeyString
*/
export async function encryptWithDetail<R>(
password: string,
dataObj: R,
salt = generateSalt(),
): Promise<DetailedEncryptionResult> {
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
Expand All @@ -37,12 +78,12 @@ export async function encryptWithKey<R>(
dataObj: R,
): Promise<EncryptionResult> {
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,
Expand All @@ -63,12 +104,45 @@ export async function encryptWithKey<R>(
* 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<R>(password: string, text: string): Promise<R> {
export async function decrypt(
password: string,
text: string,
key?: CryptoKey,
): Promise<unknown> {
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<DetailedDecryptResult> {
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,
};
}

/**
Expand All @@ -87,13 +161,13 @@ export async function decryptWithKey<R>(
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');
Expand All @@ -102,6 +176,36 @@ export async function decryptWithKey<R>(
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<CryptoKey> {
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}
*/
export async function exportKey(key: CryptoKey): Promise<string> {
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
Expand All @@ -111,7 +215,7 @@ export async function keyFromPassword(
password: string,
salt: string,
): Promise<CryptoKey> {
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(
Expand All @@ -130,8 +234,8 @@ export async function keyFromPassword(
hash: 'SHA-256',
},
key,
{ name: 'AES-GCM', length: 256 },
false,
{ name: DERIVED_KEY_FORMAT, length: 256 },
true,
['encrypt', 'decrypt'],
);

Expand Down
99 changes: 99 additions & 0 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
});
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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' };
Expand Down Expand Up @@ -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 ({
Gudahtt marked this conversation as resolved.
Show resolved Hide resolved
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),
);
});