Merge pull request #498 from shocknet/ECC-fixes

Ecc fixes
This commit is contained in:
CapDog 2021-12-14 13:24:10 -05:00 committed by GitHub
commit 4596b9bbe2
8 changed files with 446 additions and 267 deletions

View file

@ -95,7 +95,10 @@
"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

@ -16,10 +16,15 @@
"falsey", "falsey",
"GUNRPC", "GUNRPC",
"ISEA", "ISEA",
"LNDRPC",
"lndstreaming",
"PUBKEY", "PUBKEY",
"radata", "radata",
"Reqs", "Reqs",
"shockping",
"SHOCKWALLET",
"thenables", "thenables",
"unsubscription",
"uuidv" "uuidv"
] ]
} }

View file

@ -178,7 +178,7 @@ mySEA.secret = async (recipientOrSenderEpub, recipientOrSenderSEA) => {
if (recipientOrSenderSEA === null) { if (recipientOrSenderSEA === null) {
throw new TypeError( throw new TypeError(
'sea has to be nont null, args: ' + 'sea has to be non null, args: ' +
`${JSON.stringify(recipientOrSenderEpub)} -- ${JSON.stringify( `${JSON.stringify(recipientOrSenderEpub)} -- ${JSON.stringify(
recipientOrSenderSEA recipientOrSenderSEA
)}` )}`
@ -187,7 +187,7 @@ mySEA.secret = async (recipientOrSenderEpub, recipientOrSenderSEA) => {
if (recipientOrSenderEpub === recipientOrSenderSEA.pub) { if (recipientOrSenderEpub === recipientOrSenderSEA.pub) {
throw new Error( throw new Error(
'Do not use pub for mysecret, args: ' + 'Do not use pub for mySecret, args: ' +
`${JSON.stringify(recipientOrSenderEpub)} -- ${JSON.stringify( `${JSON.stringify(recipientOrSenderEpub)} -- ${JSON.stringify(
recipientOrSenderSEA recipientOrSenderSEA
)}` )}`
@ -287,7 +287,7 @@ const getGun = () => {
const getUser = () => { const getUser = () => {
if (!user.is) { if (!user.is) {
logger.warn('called getUser() without being authed') logger.warn('called getUser() without being authenticated')
throw new Error(Constants.ErrorCode.NOT_AUTH) throw new Error(Constants.ErrorCode.NOT_AUTH)
} }
return user return user

207
utils/ECC/ECC.js Normal file
View file

@ -0,0 +1,207 @@
/** @format */
const ECCrypto = require('eccrypto')
const Storage = require('node-persist')
const FieldError = require('../fieldError')
const logger = require('../../config/log')
const {
generateRandomString,
convertBufferToBase64,
processKey,
convertToEncryptedMessageResponse,
convertUTF8ToBuffer,
convertToEncryptedMessage,
convertBase64ToBuffer
} = require('./crypto')
const nodeKeyPairs = new Map()
const devicePublicKeys = new Map()
/**
* @typedef {object} EncryptedMessage
* @prop {string} ciphertext
* @prop {string} iv
* @prop {string} mac
* @prop {string} ephemPublicKey
*/
/**
* Checks if the message supplied is encrypted or not
* @param {EncryptedMessage} message
*/
const isEncryptedMessage = message =>
message &&
message.ciphertext &&
message.iv &&
message.mac &&
message.ephemPublicKey
/**
* @typedef {object} Pair
* @prop {Buffer} privateKey
* @prop {Buffer} publicKey
* @prop {string} privateKeyBase64
* @prop {string} publicKeyBase64
*/
/**
* Generates a new encryption key pair that will be used
* when communicating with the deviceId specified
* @param {string} deviceId
* @returns {Pair}
*/
const generateKeyPair = deviceId => {
try {
const existingKey = nodeKeyPairs.get(deviceId)
if (existingKey) {
logger.info('Device ID is already trusted')
return {
...existingKey,
publicKeyBase64: convertBufferToBase64(existingKey.publicKey),
privateKeyBase64: convertBufferToBase64(existingKey.privateKey)
}
}
const privateKey = ECCrypto.generatePrivate()
const publicKey = ECCrypto.getPublic(privateKey)
const privateKeyBase64 = convertBufferToBase64(privateKey)
const publicKeyBase64 = convertBufferToBase64(publicKey)
if (!Buffer.isBuffer(privateKey) || !Buffer.isBuffer(publicKey)) {
throw new Error('Invalid KeyPair Generated')
}
nodeKeyPairs.set(deviceId, {
privateKey,
publicKey
})
return {
privateKey,
publicKey,
privateKeyBase64,
publicKeyBase64
}
} catch (err) {
logger.error(
'[ENCRYPTION] An error has occurred while generating a new KeyPair',
err
)
logger.error('Device ID:', deviceId)
throw err
}
}
/**
* Checks if the specified device has a keypair generated
* @param {{ deviceId: string }} arg0
*/
const isAuthorizedDevice = ({ deviceId }) => devicePublicKeys.has(deviceId)
/**
* Generates a new keypair for the deviceId specified and
* saves its publicKey locally
* @param {{ deviceId: string, publicKey: string }} arg0
*/
const authorizeDevice = async ({ deviceId, publicKey }) => {
const hostId = await Storage.get('encryption/hostId')
devicePublicKeys.set(deviceId, convertBase64ToBuffer(publicKey))
const keyPair = generateKeyPair(deviceId)
return {
success: true,
APIPublicKey: keyPair.publicKeyBase64,
hostId
}
}
/**
* Encrypts the specified message using the specified deviceId's
* public key
* @param {{ deviceId: string, message: string | number | boolean }} arg0
* @returns {Promise<import('./crypto').EncryptedMessageResponse>}
*/
const encryptMessage = async ({ message = '', deviceId }) => {
const parsedMessage = message.toString()
// decryptMessage checks for known devices while this one checks for
// authorized ones instead, why?
const publicKey = devicePublicKeys.get(deviceId)
if (!publicKey) {
throw new FieldError({
field: 'deviceId',
message: 'encryptMessage() -> Unauthorized Device ID detected'
})
}
const processedPublicKey = processKey(publicKey)
const messageBuffer = convertUTF8ToBuffer(parsedMessage)
const encryptedMessage = await ECCrypto.encrypt(
processedPublicKey,
messageBuffer
)
const encryptedMessageResponse = {
ciphertext: encryptedMessage.ciphertext,
iv: encryptedMessage.iv,
mac: encryptedMessage.mac,
ephemPublicKey: encryptedMessage.ephemPublicKey,
metadata: {
_deviceId: deviceId,
_publicKey: publicKey
}
}
return convertToEncryptedMessageResponse(encryptedMessageResponse)
}
/**
* Decrypts the specified message using the API keypair
* associated with the specified deviceId
* @param {{ encryptedMessage: import('./crypto').EncryptedMessageResponse, deviceId: string }} arg0
*/
const decryptMessage = async ({ encryptedMessage, deviceId }) => {
// encryptMessages checks for authorized devices while this one checks for
// known ones, why?
const keyPair = nodeKeyPairs.get(deviceId)
try {
if (!keyPair) {
throw new FieldError({
field: 'deviceId',
message: 'decryptMessage() -> Unknown Device ID detected'
})
}
const processedPrivateKey = processKey(keyPair.privateKey)
const decryptedMessage = await ECCrypto.decrypt(
processedPrivateKey,
convertToEncryptedMessage(encryptedMessage)
)
const parsedMessage = decryptedMessage.toString('utf8')
return parsedMessage
} catch (err) {
logger.error(err)
if (err.message?.toLowerCase() === 'bad mac') {
logger.error(
'Bad Mac!',
err,
convertToEncryptedMessage(encryptedMessage),
!!keyPair
)
}
throw err
}
}
module.exports = {
isAuthorizedDevice,
isEncryptedMessage,
generateKeyPair,
encryptMessage,
decryptMessage,
authorizeDevice,
generateRandomString,
nodeKeyPairs,
devicePublicKeys
}

114
utils/ECC/ECC.spec.js Normal file
View file

@ -0,0 +1,114 @@
/**
* @format
*/
// @ts-check
const Path = require('path')
const Storage = require('node-persist')
const expect = require('expect')
const words = require('random-words')
const {
authorizeDevice,
decryptMessage,
encryptMessage,
generateKeyPair,
isAuthorizedDevice
} = require('./ECC')
const uuid = () => words({ exactly: 24 }).join('-')
const storageDirectory = Path.resolve(__dirname, `./.test-storage`)
console.log(`Storage directory: ${storageDirectory}`)
describe('generateKeyPair()', () => {
it('generates a keypair', () => {
const pair = 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
})
const deviceId = uuid()
const pair = generateKeyPair(deviceId)
await authorizeDevice({ deviceId, publicKey: pair.publicKeyBase64 })
expect(isAuthorizedDevice({ deviceId })).toBeTruthy()
})
})
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 = 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)
})
})

View file

@ -1,6 +1,9 @@
const { Buffer } = require("buffer"); /**
const Crypto = require("crypto"); * @format
const FieldError = require("../fieldError") */
const { Buffer } = require('buffer')
const Crypto = require('crypto')
const FieldError = require('../fieldError')
/** /**
* @typedef {object} EncryptedMessageBuffer * @typedef {object} EncryptedMessageBuffer
@ -21,102 +24,109 @@ const FieldError = require("../fieldError")
*/ */
const generateRandomString = (length = 16) => const generateRandomString = (length = 16) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
Crypto.randomBytes(length, (err, buffer) => { // Gotta halve because randomBytes returns a sequence twice the size
if (err) { Crypto.randomBytes(length / 2, (err, buffer) => {
reject(err) if (err) {
return reject(err)
} return
}
const token = buffer.toString('hex') const token = buffer.toString('hex')
resolve(token) resolve(token)
})
}) })
})
/** /**
* @param {string} value * @param {string} value
*/ */
const convertUTF8ToBuffer = (value) => Buffer.from(value, 'utf-8'); const convertUTF8ToBuffer = value => Buffer.from(value, 'utf-8')
/** /**
* @param {string} value * @param {string} value
*/ */
const convertBase64ToBuffer = (value) => Buffer.from(value, 'base64'); const convertBase64ToBuffer = value => Buffer.from(value, 'base64')
/** /**
* @param {Buffer} buffer * @param {Buffer} buffer
*/ */
const convertBufferToBase64 = (buffer) => buffer.toString("base64"); const convertBufferToBase64 = buffer => buffer.toString('base64')
/** /**
* @param {Buffer | string} key * @param {Buffer | string} key
*/ */
const processKey = (key) => { const processKey = key => {
if (Buffer.isBuffer(key)) { if (Buffer.isBuffer(key)) {
return key; return key
} }
const convertedKey = convertBase64ToBuffer(key); const convertedKey = convertBase64ToBuffer(key)
return convertedKey; return convertedKey
}; }
/** /**
* @param {EncryptedMessageBuffer | EncryptedMessageResponse} encryptedMessage * @param {EncryptedMessageBuffer | EncryptedMessageResponse} encryptedMessage
* @returns {EncryptedMessageResponse} * @returns {EncryptedMessageResponse}
*/ */
const convertToEncryptedMessageResponse = (encryptedMessage) => { const convertToEncryptedMessageResponse = encryptedMessage => {
if (Buffer.isBuffer(encryptedMessage.ciphertext) && if (
Buffer.isBuffer(encryptedMessage.iv) && Buffer.isBuffer(encryptedMessage.ciphertext) &&
Buffer.isBuffer(encryptedMessage.mac) && Buffer.isBuffer(encryptedMessage.iv) &&
Buffer.isBuffer(encryptedMessage.ephemPublicKey)) { Buffer.isBuffer(encryptedMessage.mac) &&
return { Buffer.isBuffer(encryptedMessage.ephemPublicKey)
ciphertext: convertBufferToBase64(encryptedMessage.ciphertext), ) {
iv: convertBufferToBase64(encryptedMessage.iv), return {
mac: convertBufferToBase64(encryptedMessage.mac), ciphertext: convertBufferToBase64(encryptedMessage.ciphertext),
ephemPublicKey: convertBufferToBase64(encryptedMessage.ephemPublicKey), iv: convertBufferToBase64(encryptedMessage.iv),
metadata: encryptedMessage.metadata mac: convertBufferToBase64(encryptedMessage.mac),
}; ephemPublicKey: convertBufferToBase64(encryptedMessage.ephemPublicKey),
metadata: encryptedMessage.metadata
} }
}
if (typeof encryptedMessage.ciphertext === "string") { if (typeof encryptedMessage.ciphertext === 'string') {
// @ts-ignore // @ts-ignore
return encryptedMessage; return encryptedMessage
} }
throw new FieldError({ throw new FieldError({
field: "encryptedMessage", field: 'encryptedMessage',
message: "Unknown encrypted message format" message: 'Unknown encrypted message format'
}); })
}; }
/** /**
* @param {EncryptedMessageBuffer | EncryptedMessageResponse} encryptedMessage * @param {EncryptedMessageBuffer | EncryptedMessageResponse} encryptedMessage
* @returns {EncryptedMessageBuffer} * @returns {EncryptedMessageBuffer}
*/ */
const convertToEncryptedMessage = (encryptedMessage) => { const convertToEncryptedMessage = encryptedMessage => {
if (encryptedMessage.ciphertext instanceof Buffer && if (
encryptedMessage.iv instanceof Buffer && encryptedMessage.ciphertext instanceof Buffer &&
encryptedMessage.mac instanceof Buffer && encryptedMessage.iv instanceof Buffer &&
encryptedMessage.ephemPublicKey instanceof Buffer) { encryptedMessage.mac instanceof Buffer &&
// @ts-ignore encryptedMessage.ephemPublicKey instanceof Buffer
return encryptedMessage; ) {
// @ts-ignore
return encryptedMessage
}
if (
typeof encryptedMessage.ciphertext === 'string' &&
typeof encryptedMessage.iv === 'string' &&
typeof encryptedMessage.mac === 'string' &&
typeof encryptedMessage.ephemPublicKey === 'string'
) {
return {
ciphertext: convertBase64ToBuffer(encryptedMessage.ciphertext),
iv: convertBase64ToBuffer(encryptedMessage.iv),
mac: convertBase64ToBuffer(encryptedMessage.mac),
ephemPublicKey: convertBase64ToBuffer(encryptedMessage.ephemPublicKey),
metadata: encryptedMessage.metadata
} }
if (typeof encryptedMessage.ciphertext === "string" && }
typeof encryptedMessage.iv === "string" && throw new FieldError({
typeof encryptedMessage.mac === "string" && field: 'encryptedMessage',
typeof encryptedMessage.ephemPublicKey === "string") { message: 'Unknown encrypted message format'
return { })
ciphertext: convertBase64ToBuffer(encryptedMessage.ciphertext), }
iv: convertBase64ToBuffer(encryptedMessage.iv),
mac: convertBase64ToBuffer(encryptedMessage.mac),
ephemPublicKey: convertBase64ToBuffer(encryptedMessage.ephemPublicKey),
metadata: encryptedMessage.metadata
};
}
throw new FieldError({
field: "encryptedMessage",
message: "Unknown encrypted message format"
});
};
module.exports = { module.exports = {
generateRandomString, generateRandomString,
@ -125,5 +135,5 @@ module.exports = {
convertBufferToBase64, convertBufferToBase64,
convertToEncryptedMessage, convertToEncryptedMessage,
convertToEncryptedMessageResponse, convertToEncryptedMessageResponse,
processKey, processKey
} }

33
utils/ECC/crypto.spec.js Normal file
View file

@ -0,0 +1,33 @@
/**
* @format
*/
// @ts-check
const expect = require('expect')
const {
generateRandomString,
convertBase64ToBuffer,
convertBufferToBase64
} = 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)
expect(result.length).toEqual(len)
})
})
describe('Buffer <> String <> Buffer', () => {
it('preserves values', async () => {
const rnd = await generateRandomString(24)
const asBuffer = convertBase64ToBuffer(rnd)
const asStringAgain = convertBufferToBase64(asBuffer)
expect(asStringAgain).toEqual(rnd)
})
})

View file

@ -1,194 +1 @@
/** @format */ module.exports = require('./ECC')
const ECCrypto = require('eccrypto')
const Storage = require('node-persist')
const FieldError = require('../fieldError')
const logger = require('../../config/log')
const {
generateRandomString,
convertBufferToBase64,
processKey,
convertToEncryptedMessageResponse,
convertUTF8ToBuffer,
convertToEncryptedMessage,
convertBase64ToBuffer
} = require('./crypto')
const nodeKeyPairs = new Map()
const devicePublicKeys = new Map()
/**
* @typedef {object} EncryptedMessage
* @prop {string} ciphertext
* @prop {string} iv
* @prop {string} mac
* @prop {string} ephemPublicKey
*/
/**
* Checks if the message supplied is encrypted or not
* @param {EncryptedMessage} message
*/
const isEncryptedMessage = message =>
message &&
message.ciphertext &&
message.iv &&
message.mac &&
message.ephemPublicKey
/**
* Generates a new encryption key pair that will be used
* when communicating with the deviceId specified
* @param {string} deviceId
*/
const generateKeyPair = deviceId => {
try {
const existingKey = nodeKeyPairs.get(deviceId)
if (existingKey) {
logger.warn('Device ID is already trusted')
return {
...existingKey,
publicKeyBase64: convertBufferToBase64(existingKey.publicKey),
privateKeyBase64: convertBufferToBase64(existingKey.privateKey)
}
}
const privateKey = ECCrypto.generatePrivate()
const publicKey = ECCrypto.getPublic(privateKey)
const privateKeyBase64 = convertBufferToBase64(privateKey)
const publicKeyBase64 = convertBufferToBase64(publicKey)
if (!Buffer.isBuffer(privateKey) || !Buffer.isBuffer(publicKey)) {
throw new Error('Invalid KeyPair Generated')
}
nodeKeyPairs.set(deviceId, {
privateKey,
publicKey
})
return {
privateKey,
publicKey,
privateKeyBase64,
publicKeyBase64
}
} catch (err) {
logger.error(
'[ENCRYPTION] An error has occurred while generating a new KeyPair',
err
)
logger.error('Device ID:', deviceId)
throw err
}
}
/**
* Checks if the specified device has a keypair generated
* @param {{ deviceId: string }} arg0
*/
const isAuthorizedDevice = ({ deviceId }) => devicePublicKeys.has(deviceId)
/**
* Generates a new keypair for the deviceId specified and
* saves its publicKey locally
* @param {{ deviceId: string, publicKey: string }} arg0
*/
const authorizeDevice = async ({ deviceId, publicKey }) => {
const hostId = await Storage.get('encryption/hostId')
devicePublicKeys.set(deviceId, convertBase64ToBuffer(publicKey))
const keyPair = generateKeyPair(deviceId)
return {
success: true,
APIPublicKey: keyPair.publicKeyBase64,
hostId
}
}
/**
* Encrypts the specified message using the specified deviceId's
* public key
* @param {{ deviceId: string, message: string | number | boolean }} arg0
* @returns {Promise<import('./crypto').EncryptedMessageResponse>}
*/
const encryptMessage = async ({ message = '', deviceId }) => {
const parsedMessage = message.toString()
const publicKey = devicePublicKeys.get(deviceId)
if (!publicKey) {
throw new FieldError({
field: 'deviceId',
message: 'Unauthorized Device ID detected'
})
}
const processedPublicKey = processKey(publicKey)
const messageBuffer = convertUTF8ToBuffer(parsedMessage)
const encryptedMessage = await ECCrypto.encrypt(
processedPublicKey,
messageBuffer
)
const encryptedMessageResponse = {
ciphertext: encryptedMessage.ciphertext,
iv: encryptedMessage.iv,
mac: encryptedMessage.mac,
ephemPublicKey: encryptedMessage.ephemPublicKey,
metadata: {
_deviceId: deviceId,
_publicKey: publicKey
}
}
return convertToEncryptedMessageResponse(encryptedMessageResponse)
}
/**
* Decrypts the specified message using the API keypair
* associated with the specified deviceId
* @param {{ encryptedMessage: import('./crypto').EncryptedMessageResponse, deviceId: string }} arg0
*/
const decryptMessage = async ({ encryptedMessage, deviceId }) => {
const keyPair = nodeKeyPairs.get(deviceId)
try {
if (!keyPair) {
throw new FieldError({
field: 'deviceId',
message: 'Unauthorized Device ID detected'
})
}
const processedPrivateKey = processKey(keyPair.privateKey)
const decryptedMessage = await ECCrypto.decrypt(
processedPrivateKey,
convertToEncryptedMessage(encryptedMessage)
)
const parsedMessage = decryptedMessage.toString('utf8')
return parsedMessage
} catch (err) {
logger.error(err)
if (err.message?.toLowerCase() === 'bad mac') {
logger.error(
'Bad Mac!',
err,
convertToEncryptedMessage(encryptedMessage),
!!keyPair
)
}
throw err
}
}
module.exports = {
isAuthorizedDevice,
isEncryptedMessage,
generateKeyPair,
encryptMessage,
decryptMessage,
authorizeDevice,
generateRandomString,
nodeKeyPairs,
devicePublicKeys
}