diff --git a/.eslintrc.json b/.eslintrc.json index d18a89c7..6d35aae7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -95,10 +95,7 @@ "no-warning-comments": "off", // broken - "sort-imports": "off", - - // Would require to needlessly split code into too many files. - "mocha/max-top-level-suites": "off" + "sort-imports": "off" }, "parser": "babel-eslint", "env": { diff --git a/utils/ECC/ECC.js b/utils/ECC/ECC.js index 990eac62..5420e93b 100644 --- a/utils/ECC/ECC.js +++ b/utils/ECC/ECC.js @@ -1,6 +1,7 @@ /** @format */ -const ECCrypto = require('eccrypto') const Storage = require('node-persist') +const { fork } = require('child_process') + const FieldError = require('../fieldError') const logger = require('../../config/log') const { @@ -12,6 +13,9 @@ const { convertToEncryptedMessage, convertBase64ToBuffer } = require('./crypto') +const { invoke } = require('./subprocess') + +const cryptoSubprocess = fork('utils/ECC/subprocess') const nodeKeyPairs = new Map() const devicePublicKeys = new Map() @@ -47,9 +51,9 @@ const isEncryptedMessage = message => * Generates a new encryption key pair that will be used * when communicating with the deviceId specified * @param {string} deviceId - * @returns {Pair} + * @returns {Promise} */ -const generateKeyPair = deviceId => { +const generateKeyPair = async deviceId => { try { const existingKey = nodeKeyPairs.get(deviceId) @@ -62,8 +66,8 @@ const generateKeyPair = deviceId => { } } - const privateKey = ECCrypto.generatePrivate() - const publicKey = ECCrypto.getPublic(privateKey) + const privateKey = await invoke('generatePrivate', [], cryptoSubprocess) + const publicKey = await invoke('getPublic', [privateKey], cryptoSubprocess) const privateKeyBase64 = convertBufferToBase64(privateKey) const publicKeyBase64 = convertBufferToBase64(publicKey) @@ -107,7 +111,7 @@ const isAuthorizedDevice = ({ deviceId }) => devicePublicKeys.has(deviceId) const authorizeDevice = async ({ deviceId, publicKey }) => { const hostId = await Storage.get('encryption/hostId') devicePublicKeys.set(deviceId, convertBase64ToBuffer(publicKey)) - const keyPair = generateKeyPair(deviceId) + const keyPair = await generateKeyPair(deviceId) return { success: true, @@ -137,10 +141,12 @@ const encryptMessage = async ({ message = '', deviceId }) => { const processedPublicKey = processKey(publicKey) const messageBuffer = convertUTF8ToBuffer(parsedMessage) - const encryptedMessage = await ECCrypto.encrypt( - processedPublicKey, - messageBuffer + const encryptedMessage = await invoke( + 'encrypt', + [processedPublicKey, messageBuffer], + cryptoSubprocess ) + const encryptedMessageResponse = { ciphertext: encryptedMessage.ciphertext, iv: encryptedMessage.iv, @@ -173,9 +179,10 @@ const decryptMessage = async ({ encryptedMessage, deviceId }) => { } const processedPrivateKey = processKey(keyPair.privateKey) - const decryptedMessage = await ECCrypto.decrypt( - processedPrivateKey, - convertToEncryptedMessage(encryptedMessage) + const decryptedMessage = await invoke( + 'decrypt', + [processedPrivateKey, convertToEncryptedMessage(encryptedMessage)], + cryptoSubprocess ) const parsedMessage = decryptedMessage.toString('utf8') @@ -203,5 +210,11 @@ module.exports = { authorizeDevice, generateRandomString, nodeKeyPairs, - devicePublicKeys + devicePublicKeys, + /** + * Used for tests. + */ + killECCCryptoSubprocess() { + cryptoSubprocess.kill() + } } diff --git a/utils/ECC/ECC.spec.js b/utils/ECC/ECC.spec.js index e4fbb9f7..60a7e013 100644 --- a/utils/ECC/ECC.spec.js +++ b/utils/ECC/ECC.spec.js @@ -12,103 +12,113 @@ const { decryptMessage, encryptMessage, generateKeyPair, - isAuthorizedDevice + isAuthorizedDevice, + killECCCryptoSubprocess } = require('./ECC') -const uuid = () => words({ exactly: 24 }).join('-') +const uuid = () => { + const arr = /** @type {string[]} */ (words({ exactly: 24 })) + return arr.join('-') +} const storageDirectory = Path.resolve(__dirname, `./.test-storage`) console.log(`Storage directory: ${storageDirectory}`) -describe('generateKeyPair()', () => { - it('generates a keypair', () => { - const pair = generateKeyPair(uuid()) +describe('ECC', () => { + describe('generateKeyPair()', () => { + it('generates a keypair', async () => { + expect.hasAssertions() + const pair = await generateKeyPair(uuid()) - expect(pair.privateKey).toBeInstanceOf(Buffer) - expect(typeof pair.privateKeyBase64 === 'string').toBeTruthy() - expect(pair.publicKey).toBeInstanceOf(Buffer) - expect(typeof pair.publicKeyBase64 === 'string').toBeTruthy() - }) - it('returns the same pair for the same device', () => { - const id = uuid() - const pair = generateKeyPair(id) - const pairAgain = generateKeyPair(id) - - expect(pairAgain).toStrictEqual(pair) - }) -}) - -describe('authorizeDevice()/isAuthorizedDevice()', () => { - it('authorizes a device given its ID', async () => { - expect.hasAssertions() - await Storage.init({ - dir: storageDirectory + expect(pair.privateKey).toBeInstanceOf(Buffer) + expect(typeof pair.privateKeyBase64 === 'string').toBeTruthy() + expect(pair.publicKey).toBeInstanceOf(Buffer) + expect(typeof pair.publicKeyBase64 === 'string').toBeTruthy() }) - const deviceId = uuid() - const pair = generateKeyPair(deviceId) - await authorizeDevice({ deviceId, publicKey: pair.publicKeyBase64 }) - expect(isAuthorizedDevice({ deviceId })).toBeTruthy() - }) -}) + it('returns the same pair for the same device', async () => { + expect.hasAssertions() + const id = uuid() + const pair = await generateKeyPair(id) + const pairAgain = await generateKeyPair(id) -describe('encryptMessage()/decryptMessage()', () => { - before(() => - Storage.init({ - dir: storageDirectory + expect(pairAgain).toStrictEqual(pair) }) - ) - it('throws if provided with an unauthorized device id when encrypting', async () => { - expect.hasAssertions() - const deviceId = uuid() + }) - try { - await encryptMessage({ - message: uuid(), - deviceId + describe('authorizeDevice()/isAuthorizedDevice()', () => { + it('authorizes a device given its ID', async () => { + expect.hasAssertions() + await Storage.init({ + dir: storageDirectory }) - throw new Error('encryptMessage() did not throw') - } catch (_) { - expect(true).toBeTruthy() - } + const deviceId = uuid() + const pair = await generateKeyPair(deviceId) + await authorizeDevice({ deviceId, publicKey: pair.publicKeyBase64 }) + expect(isAuthorizedDevice({ deviceId })).toBeTruthy() + }) }) - it('throws if provided with an unknown device id when decrypting', async () => { - expect.hasAssertions() - const deviceId = uuid() - try { - await decryptMessage({ + describe('encryptMessage()/decryptMessage()', () => { + before(() => + Storage.init({ + dir: storageDirectory + }) + ) + it('throws if provided with an unauthorized device id when encrypting', async () => { + expect.hasAssertions() + const deviceId = uuid() + + try { + await encryptMessage({ + message: uuid(), + deviceId + }) + throw new Error('encryptMessage() did not throw') + } catch (_) { + expect(true).toBeTruthy() + } + }) + it('throws if provided with an unknown device id when decrypting', async () => { + expect.hasAssertions() + const deviceId = uuid() + + try { + await decryptMessage({ + deviceId, + encryptedMessage: { + ciphertext: uuid(), + ephemPublicKey: uuid(), + iv: uuid(), + mac: uuid(), + metadata: uuid() + } + }) + throw new Error('decryptMessage() did not throw') + } catch (_) { + expect(true).toBeTruthy() + } + }) + it('encrypts and decrypts messages when given a known device id', async () => { + expect.hasAssertions() + const deviceId = uuid() + + const pair = await generateKeyPair(deviceId) + + await authorizeDevice({ deviceId, publicKey: pair.publicKeyBase64 }) + + const message = 'Bitcoin fixes this' + + const encryptedMessage = await encryptMessage({ deviceId, message }) + + const decrypted = await decryptMessage({ deviceId, - encryptedMessage: { - ciphertext: uuid(), - ephemPublicKey: uuid(), - iv: uuid(), - mac: uuid(), - metadata: uuid() - } + encryptedMessage }) - throw new Error('decryptMessage() did not throw') - } catch (_) { - expect(true).toBeTruthy() - } - }) - it('encrypts and decrypts messages when given a known device id', async () => { - expect.hasAssertions() - const deviceId = uuid() - const pair = generateKeyPair(deviceId) - - await authorizeDevice({ deviceId, publicKey: pair.publicKeyBase64 }) - - const message = 'Bitcoin fixes this' - - const encryptedMessage = await encryptMessage({ deviceId, message }) - - const decrypted = await decryptMessage({ - deviceId, - encryptedMessage + expect(decrypted).toEqual(message) }) - - expect(decrypted).toEqual(message) }) + + after(killECCCryptoSubprocess) }) diff --git a/utils/ECC/crypto.js b/utils/ECC/crypto.js index 6d329eb7..7f17e6f8 100644 --- a/utils/ECC/crypto.js +++ b/utils/ECC/crypto.js @@ -2,9 +2,14 @@ * @format */ const { Buffer } = require('buffer') -const Crypto = require('crypto') +const { fork } = require('child_process') + const FieldError = require('../fieldError') +const { invoke } = require('./subprocess') + +const cryptoSubprocess = fork('utils/ECC/subprocess') + /** * @typedef {object} EncryptedMessageBuffer * @prop {Buffer} ciphertext @@ -23,19 +28,15 @@ const FieldError = require('../fieldError') * @prop {any?} metadata */ -const generateRandomString = (length = 16) => - new Promise((resolve, reject) => { - // Gotta halve because randomBytes returns a sequence twice the size - Crypto.randomBytes(length / 2, (err, buffer) => { - if (err) { - reject(err) - return - } +const generateRandomString = async (length = 16) => { + if (length % 2 !== 0 || length < 2) { + throw new Error('Random string length must be an even number.') + } - const token = buffer.toString('hex') - resolve(token) - }) - }) + const res = await invoke('generateRandomString', [length], cryptoSubprocess) + + return res +} /** * @param {string} value @@ -135,5 +136,11 @@ module.exports = { convertBufferToBase64, convertToEncryptedMessage, convertToEncryptedMessageResponse, - processKey + processKey, + /** + * Used for tests. + */ + killCryptoCryptoSubprocess() { + cryptoSubprocess.kill() + } } diff --git a/utils/ECC/crypto.spec.js b/utils/ECC/crypto.spec.js index 9b9904a0..1b9c3175 100644 --- a/utils/ECC/crypto.spec.js +++ b/utils/ECC/crypto.spec.js @@ -7,27 +7,33 @@ const expect = require('expect') const { generateRandomString, convertBase64ToBuffer, - convertBufferToBase64 + convertBufferToBase64, + killCryptoCryptoSubprocess } = require('./crypto') -describe('generateRandomString()', () => { - it('creates a random string of the specified length', async () => { - expect.hasAssertions() - const len = Math.ceil(Math.random() * 100) - const result = await generateRandomString(len) +describe('crypto', () => { + describe('generateRandomString()', () => { + it('creates a random string of the specified length', async () => { + expect.hasAssertions() + const base = Math.ceil(Math.random() * 100) + const len = base % 2 !== 0 ? base + 1 : base + const result = await generateRandomString(len) - expect(result.length).toEqual(len) + expect(result.length).toEqual(len) + }) }) -}) -describe('Buffer <> String <> Buffer', () => { - it('preserves values', async () => { - const rnd = await generateRandomString(24) + describe('Buffer <> String <> Buffer', () => { + it('preserves values', async () => { + const rnd = await generateRandomString(24) - const asBuffer = convertBase64ToBuffer(rnd) + const asBuffer = convertBase64ToBuffer(rnd) - const asStringAgain = convertBufferToBase64(asBuffer) + const asStringAgain = convertBufferToBase64(asBuffer) - expect(asStringAgain).toEqual(rnd) + expect(asStringAgain).toEqual(rnd) + }) }) + + after(killCryptoCryptoSubprocess) }) diff --git a/utils/ECC/subprocess.js b/utils/ECC/subprocess.js new file mode 100644 index 00000000..987ee279 --- /dev/null +++ b/utils/ECC/subprocess.js @@ -0,0 +1,183 @@ +/** + * @format + */ +const Crypto = require('crypto') +const ECCrypto = require('eccrypto') +const uuid = require('uuid/v1') +const { Buffer } = require('buffer') +const mapValues = require('lodash/mapValues') + +const logger = require('../../config/log') + +logger.info('crypto subprocess invoked') + +process.on('uncaughtException', e => { + logger.error('Uncaught exception inside crypto subprocess:') + logger.error(e) +}) + +process.on('unhandledRejection', e => { + logger.error('Unhandled rejection inside crypto subprocess:') + logger.error(e) +}) + +/** + * @typedef {'generateRandomString' | 'convertUTF8ToBuffer' + * | 'convertBase64ToBuffer' | 'convertBufferToBase64' | 'generatePrivate' + * | 'getPublic' | 'encrypt' | 'decrypt' + * } Method + */ + +/** + * @param {any} obj + * @returns {any} + */ +const processBufferAfterSerialization = obj => { + if (typeof obj === 'object' && obj !== null) { + if (obj.type === 'Buffer') { + return Buffer.from(obj.data) + } + return mapValues(obj, processBufferAfterSerialization) + } + return obj +} + +/** + * @typedef {object} Msg + * @prop {any[]} args + * @prop {string} id + * @prop {Method} method + */ + +/** + * @param {Msg} msg + */ +const handleMsg = async msg => { + if (typeof msg !== 'object' || msg === null) { + logger.error('Msg in crypto subprocess not an object') + } + + const { id, method } = msg + const args = msg.args.map(processBufferAfterSerialization) + + try { + if (method === 'generateRandomString') { + const [length] = args + + Crypto.randomBytes(length / 2, (err, buffer) => { + if (err) { + // @ts-expect-error + process.send({ + id, + err: err.message + }) + return + } + + const token = buffer.toString('hex') + // @ts-expect-error + process.send({ + id, + payload: token + }) + }) + } + if (method === 'convertUTF8ToBuffer') { + const [value] = args + + // @ts-expect-error + process.send({ + id, + payload: Buffer.from(value, 'utf8') + }) + } + if (method === 'convertBase64ToBuffer') { + const [value] = args + + // @ts-expect-error + process.send({ + id, + payload: Buffer.from(value, 'base64') + }) + } + if (method === 'convertBufferToBase64') { + const [buffer] = args + + // @ts-expect-error + process.send({ + id, + payload: buffer.toString('base64') + }) + } + if (method === 'generatePrivate') { + // @ts-expect-error + process.send({ + id, + payload: ECCrypto.generatePrivate() + }) + } + if (method === 'getPublic') { + const [privateKey] = args + // @ts-expect-error + process.send({ + id, + payload: ECCrypto.getPublic(privateKey) + }) + } + if (method === 'encrypt') { + const [processedPublicKey, messageBuffer] = args + // @ts-expect-error + process.send({ + id, + payload: await ECCrypto.encrypt(processedPublicKey, messageBuffer) + }) + } + if (method === 'decrypt') { + const [processedPrivateKey, encryptedMessage] = args + // @ts-expect-error + process.send({ + id, + payload: await ECCrypto.decrypt(processedPrivateKey, encryptedMessage) + }) + } + } catch (e) { + // @ts-expect-error + process.send({ + err: e.message + }) + } +} + +process.on('message', handleMsg) + +/** + * @param {Method} method + * @param {any[]} args + * @param {import('child_process').ChildProcess} cryptoSubprocess + * @returns {Promise} + */ +const invoke = (method, args, cryptoSubprocess) => + new Promise((res, rej) => { + const id = uuid() + /** @param {any} msg */ + const listener = msg => { + if (msg.id === id) { + cryptoSubprocess.off('message', listener) + if (msg.err) { + rej(new Error(msg.err)) + } else { + res(processBufferAfterSerialization(msg.payload)) + } + } + } + cryptoSubprocess.on('message', listener) + cryptoSubprocess.send({ + args, + id, + method + }) + }) + +module.exports = { + invoke +}