Merge pull request #505 from shocknet/ecc-as-subprocess

Ecc as subprocess
This commit is contained in:
CapDog 2021-12-16 11:42:36 -05:00 committed by GitHub
commit 25f7d2c325
6 changed files with 340 additions and 124 deletions

View file

@ -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": {

View file

@ -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<Pair>}
*/
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()
}
}

View file

@ -12,47 +12,54 @@ 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', () => {
it('returns the same pair for the same device', async () => {
expect.hasAssertions()
const id = uuid()
const pair = generateKeyPair(id)
const pairAgain = generateKeyPair(id)
const pair = await generateKeyPair(id)
const pairAgain = await generateKeyPair(id)
expect(pairAgain).toStrictEqual(pair)
})
})
})
describe('authorizeDevice()/isAuthorizedDevice()', () => {
describe('authorizeDevice()/isAuthorizedDevice()', () => {
it('authorizes a device given its ID', async () => {
expect.hasAssertions()
await Storage.init({
dir: storageDirectory
})
const deviceId = uuid()
const pair = generateKeyPair(deviceId)
const pair = await generateKeyPair(deviceId)
await authorizeDevice({ deviceId, publicKey: pair.publicKeyBase64 })
expect(isAuthorizedDevice({ deviceId })).toBeTruthy()
})
})
})
describe('encryptMessage()/decryptMessage()', () => {
describe('encryptMessage()/decryptMessage()', () => {
before(() =>
Storage.init({
dir: storageDirectory
@ -96,7 +103,7 @@ describe('encryptMessage()/decryptMessage()', () => {
expect.hasAssertions()
const deviceId = uuid()
const pair = generateKeyPair(deviceId)
const pair = await generateKeyPair(deviceId)
await authorizeDevice({ deviceId, publicKey: pair.publicKeyBase64 })
@ -111,4 +118,7 @@ describe('encryptMessage()/decryptMessage()', () => {
expect(decrypted).toEqual(message)
})
})
after(killECCCryptoSubprocess)
})

View file

@ -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()
}
}

View file

@ -7,20 +7,23 @@ const expect = require('expect')
const {
generateRandomString,
convertBase64ToBuffer,
convertBufferToBase64
convertBufferToBase64,
killCryptoCryptoSubprocess
} = require('./crypto')
describe('generateRandomString()', () => {
describe('crypto', () => {
describe('generateRandomString()', () => {
it('creates a random string of the specified length', async () => {
expect.hasAssertions()
const len = Math.ceil(Math.random() * 100)
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)
})
})
})
describe('Buffer <> String <> Buffer', () => {
describe('Buffer <> String <> Buffer', () => {
it('preserves values', async () => {
const rnd = await generateRandomString(24)
@ -30,4 +33,7 @@ describe('Buffer <> String <> Buffer', () => {
expect(asStringAgain).toEqual(rnd)
})
})
after(killCryptoCryptoSubprocess)
})

183
utils/ECC/subprocess.js Normal file
View file

@ -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<any>}
*/
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
}