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

Uses native crypto APIs #276

Merged
merged 3 commits into from
May 19, 2022
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@types/semver": "^7.3.9",
"@types/shelljs": "^0.8.11",
"@types/yargs": "^17.0.10",
"@types/web": "^0.0.65",
"@typescript-eslint/eslint-plugin": "^5.6.0",
"@typescript-eslint/parser": "^5.6.0",
"conventional-recommended-bump": "^6.1.0",
Expand Down
4 changes: 1 addition & 3 deletions packages/keystore/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@
"dependencies": {
"@ethersproject/bytes": "^5.6.1",
"@ethersproject/pbkdf2": "^5.6.0",
"@ethersproject/random": "^5.6.0",
"@types/aes-js": "^3.1.1",
"aes-js": "^3.1.2"
"@ethersproject/random": "^5.6.0"
},
"scripts": {
"build": "tsup --dts"
Expand Down
52 changes: 52 additions & 0 deletions packages/keystore/src/aes-ctr-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { Keystore } from './aes-ctr';
import { bufferFromString, stringFromBuffer, keyFromPassword } from './aes-ctr';
import { crypto } from './universal-crypto';

const ALGORITHM = 'aes-256-ctr';

const randomBytes = (length: number) => crypto.randomBytes(length);

/**
* Encrypts a data object that can be any serializable value using
* a provided password.
*
* @returns Promise<Keystore> object
*/
export async function encrypt<T>(password: string, data: T): Promise<Keystore> {
const iv = randomBytes(16);
const salt = randomBytes(32);
const secret = keyFromPassword(password, salt);
const dataBuffer = Uint8Array.from(Buffer.from(JSON.stringify(data), 'utf-8'));

const cipher = crypto.createCipheriv(ALGORITHM, secret, iv);
let cipherData = cipher.update(dataBuffer);
cipherData = Buffer.concat([cipherData, cipher.final()]);

return {
data: stringFromBuffer(cipherData),
iv: stringFromBuffer(iv),
salt: stringFromBuffer(salt),
};
}

/**
* Given a password and a keystore object, decrypts the text and returns
* the resulting value
*/
export async function decrypt<T>(password: string, keystore: Keystore): Promise<T> {
const iv = bufferFromString(keystore.iv);
const salt = bufferFromString(keystore.salt);
const secret = keyFromPassword(password, salt);
const encryptedText = bufferFromString(keystore.data);

const decipher = crypto.createDecipheriv(ALGORITHM, secret, iv);
const decrypted = decipher.update(encryptedText);
const deBuff = Buffer.concat([decrypted, decipher.final()]);
const decryptedData = Buffer.from(deBuff).toString('utf-8');

try {
return JSON.parse(decryptedData);
} catch {
throw new Error('Invalid credentials');
}
}
61 changes: 61 additions & 0 deletions packages/keystore/src/aes-ctr-web.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Keystore } from './aes-ctr';
import { bufferFromString, stringFromBuffer, keyFromPassword } from './aes-ctr';
import { crypto } from './universal-crypto';

const ALGORITHM = 'AES-CTR';

const randomBytes = (length: number) =>
crypto.getRandomValues(new Uint8Array(length) as unknown as number);

/**
* Encrypts a data object that can be any serializable value using
* a provided password.
*
* @returns Promise<Keystore> object
*/
export async function encrypt<T>(password: string, data: T): Promise<Keystore> {
const iv = randomBytes(16);
const salt = randomBytes(32);
const secret = keyFromPassword(password, salt);
const dataBuffer = Uint8Array.from(Buffer.from(JSON.stringify(data), 'utf-8'));
const alg = {
name: ALGORITHM,
counter: iv,
length: 64,
};
const key = await crypto.subtle.importKey('raw', secret, alg, false, ['encrypt']);
const encBuffer = await crypto.subtle.encrypt(alg, key, dataBuffer);

return {
data: stringFromBuffer(encBuffer),
iv: stringFromBuffer(iv),
salt: stringFromBuffer(salt),
};
}

/**
* Given a password and a keystore object, decrypts the text and returns
* the resulting value
*/
export async function decrypt<T>(password: string, keystore: Keystore): Promise<T> {
const iv = bufferFromString(keystore.iv);
const salt = bufferFromString(keystore.salt);
const secret = keyFromPassword(password, salt);
const encryptedText = bufferFromString(keystore.data);

const alg = {
name: ALGORITHM,
counter: iv,
length: 64,
};
const key = await crypto.subtle.importKey('raw', secret, alg, false, ['decrypt']);

const ptBuffer = await crypto.subtle.decrypt(alg, key, encryptedText);
const decryptedData = new TextDecoder().decode(ptBuffer);

try {
return JSON.parse(decryptedData);
} catch {
throw new Error('Invalid credentials');
}
}
49 changes: 49 additions & 0 deletions packages/keystore/src/aes-ctr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { arrayify } from '@ethersproject/bytes';
import { pbkdf2 } from '@ethersproject/pbkdf2';

import { strategy } from './universal-crypto';

export interface Keystore {
data: string;
iv: string;
salt: string;
}

export function bufferFromString(
string: string,
encoding: 'utf-8' | 'base64' = 'base64'
): Uint8Array {
if (strategy === 'Node') {
return Buffer.from(string, encoding);
}
if (encoding === 'utf-8') {
return new TextEncoder().encode(string);
}

return new Uint8Array(
atob(string)
.split('')
.map((c) => c.charCodeAt(0))
);
}

export function stringFromBuffer(
buffer: Uint8Array,
encoding: 'utf-8' | 'base64' = 'base64'
): string {
if (strategy === 'Node') {
return Buffer.from(buffer).toString(encoding);
}

return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer) as unknown as number[]));
}

/**
* Generate a pbkdf2 key from a password and random salt
*/
export function keyFromPassword(password: string, saltBuffer: Uint8Array): Uint8Array {
const passBuffer = bufferFromString(String(password).normalize('NFKC'), 'utf-8');
const key = pbkdf2(passBuffer, saltBuffer, 100000, 32, 'sha256');

return arrayify(key);
}
36 changes: 33 additions & 3 deletions packages/keystore/src/keystore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,38 @@ describe('Keystore', () => {
};
const encryptedResult = await keystore.encrypt(password, data);

expect(() => {
keystore.decrypt(`${password}123`, encryptedResult);
}).toThrowError('Invalid credentials');
await expect(async () => {
await keystore.decrypt(`${password}123`, encryptedResult);
}).rejects.toThrow('Invalid credentials');
});

test('Decrypt Loop', async () => {
const INPUTS = [
{
data: '07yJczBTonXWyKdJfEcx',
iv: 'MROpfbpxAjLZ2LxK0tlN0g==',
salt: 'sisrSI8eavMAAXg2N7mncIp6A9pk+bEEvxtC/O5HPK0=',
},
{
data: 'tQomRkvp3tZPZYKG8xiP',
iv: 'c5SmUHGOJqlwfE3FJJ3w+g==',
salt: 'RWcKoopiyTZkp7ufS8E8iOwXb6biVyYHndpkZz4Bnno=',
},
{
data: 'wRA2KEAuNPBvrlPc2thy',
iv: 'hE6jwBCbm7IpmWZoZN9MJw==',
salt: '8kT/g8Pq+NACIM0HrKJ5XhDrYijk6/tTt79EfNAC0Yw=',
},
];
const password = '0b540281-f87b-49ca-be37-2264c7f260f7';
const data = {
name: 'test',
};

for (let i = 0; i < INPUTS.length; i += 1) {
const decryptedResult = await keystore.decrypt(password, INPUTS[i]);

expect(decryptedResult).toEqual(data);
}
});
});
64 changes: 12 additions & 52 deletions packages/keystore/src/keystore.ts
Original file line number Diff line number Diff line change
@@ -1,67 +1,27 @@
import type { BytesLike } from '@ethersproject/bytes';
import { arrayify } from '@ethersproject/bytes';
import { pbkdf2 } from '@ethersproject/pbkdf2';
import { randomBytes } from '@ethersproject/random';
// TODO: Remove EAS and use native crypto libs on browser and node
import aes from 'aes-js';
import type { Keystore } from './aes-ctr';
import { encrypt as encNode, decrypt as decNode } from './aes-ctr-node';
import { encrypt as encWeb, decrypt as decWeb } from './aes-ctr-web';
import { strategy } from './universal-crypto';

export interface Keystore {
data: string;
iv: string;
salt: string;
}

/**
* Generate a pbkdf2 key from a password and random salt
*/
export function keyFromPassword(password: string, salt: BytesLike): Uint8Array {
const passBuffer = Buffer.from(String(password).normalize('NFKC'), 'utf-8');
const saltBuffer = arrayify(salt);
const key = pbkdf2(passBuffer, saltBuffer, 100000, 32, 'sha256');

return arrayify(key);
}
export type { Keystore } from './aes-ctr';
export { keyFromPassword } from './aes-ctr';

/**
* Encrypts a data object that can be any serializable value using
* a provided password.
*
* @returns Promise<Keystore> Keystore object
*/
export function encrypt<T>(password: string, data: T): Keystore {
const iv = randomBytes(16);
const salt = randomBytes(32);
const secret = keyFromPassword(password, salt);
const dataBuffer = Uint8Array.from(Buffer.from(JSON.stringify(data), 'utf-8'));
const counter = new aes.Counter(iv);
// eslint-disable-next-line new-cap
const aesCtr = new aes.ModeOfOperation.ctr(secret, counter);
const cipherdata = aesCtr.encrypt(dataBuffer);

return {
data: Buffer.from(cipherdata).toString('base64'),
iv: Buffer.from(iv).toString('base64'),
salt: Buffer.from(salt).toString('base64'),
};
export async function encrypt<T>(password: string, data: T): Promise<Keystore> {
return strategy === 'Node' ? encNode<T>(password, data) : encWeb<T>(password, data);
}

/**
* Given a password and a keystore object, decrypts the text and returns
* the resulting value
*
* @returns Promise<T> T object
*/
export function decrypt<T>(password: string, keystore: Keystore): T {
const iv = arrayify(Buffer.from(keystore.iv, 'base64'));
const salt = arrayify(Buffer.from(keystore.salt, 'base64'));
const secret = keyFromPassword(password, salt);

const counter = new aes.Counter(iv);
// eslint-disable-next-line new-cap
const aesCtr = new aes.ModeOfOperation.ctr(secret, counter);
const decryptedData = aesCtr.decrypt(Buffer.from(keystore.data, 'base64'));

try {
return JSON.parse(Buffer.from(decryptedData).toString('utf-8'));
} catch {
throw new Error('Invalid credentials');
}
export async function decrypt<T>(password: string, keystore: Keystore): Promise<T> {
return strategy === 'Node' ? decNode<T>(password, keystore) : decWeb<T>(password, keystore);
}
34 changes: 34 additions & 0 deletions packages/keystore/src/universal-crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { createCipheriv, createDecipheriv } from 'crypto';
luizstacio marked this conversation as resolved.
Show resolved Hide resolved

type UniversalCrypto = {
getRandomValues: (length: number) => Uint8Array;
randomBytes: (length: number) => Uint8Array;
subtle: SubtleCrypto;
createCipheriv: typeof createCipheriv;
createDecipheriv: typeof createDecipheriv;
};
let selectedCrypto;
let selectedStrategy: 'Node' | 'Web' = 'Node';

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (typeof globalThis !== 'undefined' && globalThis.crypto) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
selectedCrypto = globalThis.crypto;
selectedStrategy = 'Web';
}

if (!selectedCrypto && typeof require === 'function') {
try {
// eslint-disable-next-line global-require
selectedCrypto = require('crypto');
selectedStrategy = 'Node';
} catch (error) {
// eslint-disable-next-line no-console
console.error('keystore expects a standard Web browser or Node environment.', error);
}
}

export const crypto: UniversalCrypto = selectedCrypto;
export const strategy = selectedStrategy;
Loading