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", "no-warning-comments": "off",
// broken // broken
"sort-imports": "off", "sort-imports": "off"
// Would require to needlessly split code into too many files.
"mocha/max-top-level-suites": "off"
}, },
"parser": "babel-eslint", "parser": "babel-eslint",
"env": { "env": {

View file

@ -1,6 +1,7 @@
/** @format */ /** @format */
const ECCrypto = require('eccrypto')
const Storage = require('node-persist') const Storage = require('node-persist')
const { fork } = require('child_process')
const FieldError = require('../fieldError') const FieldError = require('../fieldError')
const logger = require('../../config/log') const logger = require('../../config/log')
const { const {
@ -12,6 +13,9 @@ const {
convertToEncryptedMessage, convertToEncryptedMessage,
convertBase64ToBuffer convertBase64ToBuffer
} = require('./crypto') } = require('./crypto')
const { invoke } = require('./subprocess')
const cryptoSubprocess = fork('utils/ECC/subprocess')
const nodeKeyPairs = new Map() const nodeKeyPairs = new Map()
const devicePublicKeys = new Map() const devicePublicKeys = new Map()
@ -47,9 +51,9 @@ const isEncryptedMessage = message =>
* Generates a new encryption key pair that will be used * Generates a new encryption key pair that will be used
* when communicating with the deviceId specified * when communicating with the deviceId specified
* @param {string} deviceId * @param {string} deviceId
* @returns {Pair} * @returns {Promise<Pair>}
*/ */
const generateKeyPair = deviceId => { const generateKeyPair = async deviceId => {
try { try {
const existingKey = nodeKeyPairs.get(deviceId) const existingKey = nodeKeyPairs.get(deviceId)
@ -62,8 +66,8 @@ const generateKeyPair = deviceId => {
} }
} }
const privateKey = ECCrypto.generatePrivate() const privateKey = await invoke('generatePrivate', [], cryptoSubprocess)
const publicKey = ECCrypto.getPublic(privateKey) const publicKey = await invoke('getPublic', [privateKey], cryptoSubprocess)
const privateKeyBase64 = convertBufferToBase64(privateKey) const privateKeyBase64 = convertBufferToBase64(privateKey)
const publicKeyBase64 = convertBufferToBase64(publicKey) const publicKeyBase64 = convertBufferToBase64(publicKey)
@ -107,7 +111,7 @@ const isAuthorizedDevice = ({ deviceId }) => devicePublicKeys.has(deviceId)
const authorizeDevice = async ({ deviceId, publicKey }) => { const authorizeDevice = async ({ deviceId, publicKey }) => {
const hostId = await Storage.get('encryption/hostId') const hostId = await Storage.get('encryption/hostId')
devicePublicKeys.set(deviceId, convertBase64ToBuffer(publicKey)) devicePublicKeys.set(deviceId, convertBase64ToBuffer(publicKey))
const keyPair = generateKeyPair(deviceId) const keyPair = await generateKeyPair(deviceId)
return { return {
success: true, success: true,
@ -137,10 +141,12 @@ const encryptMessage = async ({ message = '', deviceId }) => {
const processedPublicKey = processKey(publicKey) const processedPublicKey = processKey(publicKey)
const messageBuffer = convertUTF8ToBuffer(parsedMessage) const messageBuffer = convertUTF8ToBuffer(parsedMessage)
const encryptedMessage = await ECCrypto.encrypt( const encryptedMessage = await invoke(
processedPublicKey, 'encrypt',
messageBuffer [processedPublicKey, messageBuffer],
cryptoSubprocess
) )
const encryptedMessageResponse = { const encryptedMessageResponse = {
ciphertext: encryptedMessage.ciphertext, ciphertext: encryptedMessage.ciphertext,
iv: encryptedMessage.iv, iv: encryptedMessage.iv,
@ -173,9 +179,10 @@ const decryptMessage = async ({ encryptedMessage, deviceId }) => {
} }
const processedPrivateKey = processKey(keyPair.privateKey) const processedPrivateKey = processKey(keyPair.privateKey)
const decryptedMessage = await ECCrypto.decrypt( const decryptedMessage = await invoke(
processedPrivateKey, 'decrypt',
convertToEncryptedMessage(encryptedMessage) [processedPrivateKey, convertToEncryptedMessage(encryptedMessage)],
cryptoSubprocess
) )
const parsedMessage = decryptedMessage.toString('utf8') const parsedMessage = decryptedMessage.toString('utf8')
@ -203,5 +210,11 @@ module.exports = {
authorizeDevice, authorizeDevice,
generateRandomString, generateRandomString,
nodeKeyPairs, nodeKeyPairs,
devicePublicKeys devicePublicKeys,
/**
* Used for tests.
*/
killECCCryptoSubprocess() {
cryptoSubprocess.kill()
}
} }

View file

@ -12,103 +12,113 @@ const {
decryptMessage, decryptMessage,
encryptMessage, encryptMessage,
generateKeyPair, generateKeyPair,
isAuthorizedDevice isAuthorizedDevice,
killECCCryptoSubprocess
} = require('./ECC') } = 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`) const storageDirectory = Path.resolve(__dirname, `./.test-storage`)
console.log(`Storage directory: ${storageDirectory}`) console.log(`Storage directory: ${storageDirectory}`)
describe('generateKeyPair()', () => { describe('ECC', () => {
it('generates a keypair', () => { describe('generateKeyPair()', () => {
const pair = generateKeyPair(uuid()) it('generates a keypair', async () => {
expect.hasAssertions()
const pair = await generateKeyPair(uuid())
expect(pair.privateKey).toBeInstanceOf(Buffer) expect(pair.privateKey).toBeInstanceOf(Buffer)
expect(typeof pair.privateKeyBase64 === 'string').toBeTruthy() expect(typeof pair.privateKeyBase64 === 'string').toBeTruthy()
expect(pair.publicKey).toBeInstanceOf(Buffer) expect(pair.publicKey).toBeInstanceOf(Buffer)
expect(typeof pair.publicKeyBase64 === 'string').toBeTruthy() 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
}) })
const deviceId = uuid() it('returns the same pair for the same device', async () => {
const pair = generateKeyPair(deviceId) expect.hasAssertions()
await authorizeDevice({ deviceId, publicKey: pair.publicKeyBase64 }) const id = uuid()
expect(isAuthorizedDevice({ deviceId })).toBeTruthy() const pair = await generateKeyPair(id)
}) const pairAgain = await generateKeyPair(id)
})
describe('encryptMessage()/decryptMessage()', () => { expect(pairAgain).toStrictEqual(pair)
before(() =>
Storage.init({
dir: storageDirectory
}) })
) })
it('throws if provided with an unauthorized device id when encrypting', async () => {
expect.hasAssertions()
const deviceId = uuid()
try { describe('authorizeDevice()/isAuthorizedDevice()', () => {
await encryptMessage({ it('authorizes a device given its ID', async () => {
message: uuid(), expect.hasAssertions()
deviceId await Storage.init({
dir: storageDirectory
}) })
throw new Error('encryptMessage() did not throw') const deviceId = uuid()
} catch (_) { const pair = await generateKeyPair(deviceId)
expect(true).toBeTruthy() 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 { describe('encryptMessage()/decryptMessage()', () => {
await 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, deviceId,
encryptedMessage: { 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 = generateKeyPair(deviceId) expect(decrypted).toEqual(message)
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)
}) })
after(killECCCryptoSubprocess)
}) })

View file

@ -2,9 +2,14 @@
* @format * @format
*/ */
const { Buffer } = require('buffer') const { Buffer } = require('buffer')
const Crypto = require('crypto') const { fork } = require('child_process')
const FieldError = require('../fieldError') const FieldError = require('../fieldError')
const { invoke } = require('./subprocess')
const cryptoSubprocess = fork('utils/ECC/subprocess')
/** /**
* @typedef {object} EncryptedMessageBuffer * @typedef {object} EncryptedMessageBuffer
* @prop {Buffer} ciphertext * @prop {Buffer} ciphertext
@ -23,19 +28,15 @@ const FieldError = require('../fieldError')
* @prop {any?} metadata * @prop {any?} metadata
*/ */
const generateRandomString = (length = 16) => const generateRandomString = async (length = 16) => {
new Promise((resolve, reject) => { if (length % 2 !== 0 || length < 2) {
// Gotta halve because randomBytes returns a sequence twice the size throw new Error('Random string length must be an even number.')
Crypto.randomBytes(length / 2, (err, buffer) => { }
if (err) {
reject(err)
return
}
const token = buffer.toString('hex') const res = await invoke('generateRandomString', [length], cryptoSubprocess)
resolve(token)
}) return res
}) }
/** /**
* @param {string} value * @param {string} value
@ -135,5 +136,11 @@ module.exports = {
convertBufferToBase64, convertBufferToBase64,
convertToEncryptedMessage, convertToEncryptedMessage,
convertToEncryptedMessageResponse, convertToEncryptedMessageResponse,
processKey processKey,
/**
* Used for tests.
*/
killCryptoCryptoSubprocess() {
cryptoSubprocess.kill()
}
} }

View file

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

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
}