diff --git a/ABFactory.js b/ABFactory.js index db4d939..51e2654 100644 --- a/ABFactory.js +++ b/ABFactory.js @@ -9,11 +9,12 @@ const _ = require("lodash"); const Knex = require("knex"); const moment = require("moment"); const { nanoid } = require("nanoid"); +const Papa = require("papaparse"); const { serializeError, deserializeError } = require("serialize-error"); const uuid = require("uuid"); -const Papa = require("papaparse"); var ABFactoryCore = require("./core/ABFactoryCore"); +const SecretManager = require("./platform/ABSecretManager"); function stringifyErrors(param) { if (param instanceof Error) { @@ -186,7 +187,7 @@ class ABFactory extends ABFactoryCore { * @param {string} date String of a date you want converted * @return {string} */ - toSQLDate: function (date) { + toSQLDate: function(date) { return moment(date).format("YYYY-MM-DD"); // return moment(date).format("YYYY-MM-DD 00:00:00"); }, @@ -197,7 +198,7 @@ class ABFactory extends ABFactoryCore { * @param {string} date String of a date you want converted * @return {string} */ - toSQLDateTime: function (date) { + toSQLDateTime: function(date) { return moment(date).utc().format("YYYY-MM-DD HH:mm:ss"); }, @@ -324,13 +325,14 @@ class ABFactory extends ABFactoryCore { (Object.keys(platformRules) || []).forEach((k) => { this.rules[k] = platformRules[k]; }); + + this.Secret = new SecretManager(this); } - // init() { - // super.init().then(()=>{ - // // perform any local setups here. - // }); - // } + async init() { + await super.init(); + await this.Secret.init(this); + } // // Definitions @@ -517,6 +519,7 @@ class ABFactory extends ABFactoryCore { // // Utilities // + clone(value) { return _.clone(value); } diff --git a/platform/ABObjectApi.js b/platform/ABObjectApi.js index e6aa8a3..31dc73c 100644 --- a/platform/ABObjectApi.js +++ b/platform/ABObjectApi.js @@ -1,11 +1,5 @@ -const crypto = require("crypto"); - const ABObjectApiCore = require("../core/ABObjectApiCore"); -const CRYPTO_ALGORITHM = "aes-256-gcm"; -const KEY_LENGTH = 32; -const VI_LENGTH = 16; - module.exports = class ABObjectApi extends ABObjectApiCore { /** * migrateCreate @@ -16,32 +10,13 @@ module.exports = class ABObjectApi extends ABObjectApiCore { * the Knex connection. * @return {Promise} */ - async migrateCreate(req, knex) { + async migrateCreate(/*req, knex*/) { const createTasks = []; - const modelKey = this.AB.objectKey().model(); - const modelSecret = this.AB.objectSecret().model(); - - // Create/Store the key of the API Object - const privateKey = this._createPrivateKey(); - createTasks.push( - modelKey.create({ - Key: privateKey, - DefinitionID: this.id, - }) - ); // Encrypt/Store secrets of the API Object - (this.secrets ?? []).forEach((secret) => { - const encrypted = this._encryptSecret(privateKey, secret.value); - - createTasks.push( - modelSecret.create({ - Name: secret.name, - Secret: encrypted, - DefinitionID: this.id, - }) - ); - }); + if (this.secrets) { + this.AB.Secret.create(this.id, ...this.secrets); + } return Promise.all(createTasks); } @@ -85,81 +60,6 @@ module.exports = class ABObjectApi extends ABObjectApiCore { } async getSecretValue(secretName) { - if (!secretName) return null; - - const privateKey = await this._getPrivateKey(); - if (!privateKey) return null; - - const modelSecret = this.AB.objectSecret().model(); - const list = await modelSecret.find({ - where: { - DefinitionID: this.id, - Name: secretName, - }, - limit: 1, - }); - const secret = list?.[0]?.Secret ?? ""; - if (!secret) return null; - - return this._decryptSecret(privateKey, secret); - } - - _createPrivateKey() { - const key = crypto.randomBytes(KEY_LENGTH); - - return key.toString("hex"); - } - - async _getPrivateKey() { - const modelKey = this.AB.objectKey().model(); - const list = await modelKey.find({ - where: { DefinitionID: this.id }, - limit: 1, - }); - - return list[0]?.Key ?? null; - } - - _encryptSecret(key, text) { - const iv = crypto.randomBytes(VI_LENGTH); - const cipher = crypto.createCipheriv( - CRYPTO_ALGORITHM, - Buffer.from(key, "hex"), - iv - ); - - const encrypted = cipher.update(Buffer.from(text, "utf-8")); - cipher.final(); - - return Buffer.concat([encrypted, iv, cipher.getAuthTag()]).toString( - "hex" - ); - } - - _decryptSecret(key, encrypted) { - const encryptedBuffer = Buffer.from(encrypted, "hex"); - const text = encryptedBuffer.slice( - 0, - encryptedBuffer.length - VI_LENGTH * 2 - ); - const vi = encryptedBuffer.slice( - encryptedBuffer.length - VI_LENGTH * 2, - encryptedBuffer.length - VI_LENGTH - ); - const authTag = encryptedBuffer.slice( - encryptedBuffer.length - VI_LENGTH, - encryptedBuffer.length - ); - - const decipher = crypto.createDecipheriv( - CRYPTO_ALGORITHM, - Buffer.from(key, "hex"), - vi - ); - decipher.setAuthTag(authTag); - - let decrypted = decipher.update(text); - decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted.toString("utf-8"); + return this.AB.Secret.getValue(this.id, secretName); } }; diff --git a/platform/ABSecretManager.js b/platform/ABSecretManager.js new file mode 100644 index 0000000..4aed9e3 --- /dev/null +++ b/platform/ABSecretManager.js @@ -0,0 +1,172 @@ +const crypto = require("crypto"); + +// Encryption settings +const CRYPTO_ALGORITHM = "aes-256-gcm"; +const KEY_LENGTH = 32; +const IV_LENGTH = 16; + +class SecretManager { + async init(AB) { + this.secret = AB.objectSecret().model(); + this.key = AB.objectKey().model(); + } + + /** + * Retrieves a saved private key for a given definition. Will create and save + * one if it does not exist. + * @param {string} definitionID unique id of the definition the key is for + * @resolves {string} private key + */ + async _getKey(definitionID) { + const cacheKey = `_cachePK_${definitionID}`; + if (!this[cacheKey]) { + // this[cacheKey] is promise to avoid looking up / creating multiple + // times. it will resolve with the key + this[cacheKey] = (async () => { + // Check if the database has one + const [key] = + (await this.key.find({ + where: { DefinitionID: definitionID }, + limit: 1, + })) ?? []; + if (key) return key.Key; + // If not we'll create one + const newKey = crypto.randomBytes(KEY_LENGTH).toString("hex"); + await this.key.create({ + Key: newKey, + DefinitionID: definitionID, + }); + return newKey; + })(); + + // We don't want to cache these keys in memory for a long time, but also + // don't want to read from the database each time. One defintion might + // have multiple secrets that will be read or written within a few + // seconds. So we'll cache it, but also schedule a cleanup in 5 mins. + const cacheCleanup = `${cacheKey}_cleanup`; + clearTimeout(this[cacheCleanup]); + this[cacheCleanup] = setTimeout(async () => { + await this[cacheKey]; + delete this[cacheKey]; + delete this[cacheCleanup]; + }, 5 * 60 * 1000); + } + return await this[cacheKey]; + } + + /** + * Deletes a secret from the database + */ + delete(definitionID, name) { + return this.secret + .find({ + where: { + DefinitionID: definitionID, + Name: name, + }, + limit: 1, + }) + .then((result) => { + const toDelete = result?.[0]?.uuid; + if (toDelete) this.secret.delete(toDelete); + }); + } + + /** + * Encyrpts and stores a secret value + * @param {string} defintionID - unique id of the definition this secret + * belongs to + * @param {object} secret any number of secrets to add + * @param {string} secret.name to refernce this secret by + * @param {string} secret.value the secret to be encrypted + */ + async create(defintionID, ...secrets) { + const pk = await this._getKey(defintionID); + const saves = secrets.map(({ name, value }) => { + // Encrypt + const encryptedValue = this._encrypt(pk, value); + + // Save to DB + return this.secret.create({ + Name: name, + Secret: encryptedValue, + DefinitionID: defintionID, + }); + }); + await Promise.all(saves); + } + + /** + * Retrieve and decrypt a stored secret + * @param {string} defintionID - unique id of the definition the secret + * belongs to + * @param {string} name of the secret + */ + async getValue(definitionID, name) { + const pk = await this._getKey(definitionID); + + // Lookup the secret from the DB + const list = await this.secret.find({ + where: { + DefinitionID: definitionID, + Name: name, + }, + limit: 1, + }); + const secret = list?.[0]?.Secret ?? ""; + if (!secret) return null; + + return this._decrypt(pk, secret); + } + + /** + * get a list of sotred secret names for a given definition + */ + async getStoredNames(definitionID) { + // Lookup the secrets from the DB + const list = await this.secret.find({ + where: { + DefinitionID: definitionID, + }, + }); + return list?.map?.((secret) => secret.Name); + } + + _encrypt(pk, value) { + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv( + CRYPTO_ALGORITHM, + Buffer.from(pk, "hex"), + iv + ); + const encrypted = cipher.update(Buffer.from(value, "utf-8")); + cipher.final(); + const encryptedValue = Buffer.concat([ + encrypted, + iv, + cipher.getAuthTag(), + ]).toString("hex"); + + return encryptedValue; + } + + _decrypt(pk, encrypted) { + const encryptedBuffer = Buffer.from(encrypted, "hex"); + const bufferLength = encryptedBuffer.length; + const diff = bufferLength - IV_LENGTH; + const text = encryptedBuffer.subarray(0, diff * 2); + const iv = encryptedBuffer.subarray(diff * 2, diff); + const authTag = encryptedBuffer.subarray(diff, bufferLength); + const decipher = crypto.createDecipheriv( + CRYPTO_ALGORITHM, + Buffer.from(pk, "hex"), + iv + ); + decipher.setAuthTag(authTag); + let decrypted = decipher.update(text); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString("utf-8"); + } +} + +module.exports = SecretManager; diff --git a/platform/process/tasks/ABProcessTaskServiceApi.js b/platform/process/tasks/ABProcessTaskServiceApi.js new file mode 100644 index 0000000..fb9c29b --- /dev/null +++ b/platform/process/tasks/ABProcessTaskServiceApi.js @@ -0,0 +1,97 @@ +const axios = require("axios"); +const ApiTaskCore = require("../../../core/process/tasks/ABProcessTaskServiceApiCore"); + +module.exports = class ApiTask extends ApiTaskCore { + /** + * @method do() + * this method actually performs the action for this task. + * @param {obj} instance + * the instance data of the running process + * @param {Knex.Transaction?} trx + * (optional) Knex Transaction instance. + * @param {ABUtil.reqService} req + * an instance of the current request object for performing tenant + * based operations. + * @return {Promise} + * resolve(true/false) : true if the task is completed. + * false if task is still waiting + */ + async do(instance /*, trx, req */) { + this.instance = instance; + const response = await this.request(instance); + this.stateUpdate(instance, { rawResponse: response.data }); + this.stateCompleted(instance); + } + + static defaults() { + return { key: "Api" }; + } + + async request(instance) { + const [url, headers, data] = await Promise.all([ + this.renderText(this.url, instance), + this.prepareHeaders(instance), + this.renderText(this.body, instance), + ]); + + const opts = { + url, + method: this.method, + headers, + data: data.replace(/\n/g, ""), + }; + + const response = await axios(opts); + + return response; + } + + async prepareHeaders(instance) { + const reqHeaders = {}; + await Promise.all( + this.headers.map(async (header) => { + reqHeaders[header.key] = await this.renderText( + header.value, + instance, + ); + }), + ); + return reqHeaders; + } + + async renderText(text, instance) { + if (!text) return ""; + const secretPattern = /<%= Secret: (.+?) %>/g; + if (secretPattern.test(text)) { + secretPattern.test(""); // Need to reset the "lastIndex" val of our regex + const secretNames = [...text.matchAll(secretPattern)].map((m) => m[1]); + const secrets = {}; + await Promise.all( + secretNames.map(async (s) => { + secrets[s] = await this.AB.Secret.getValue(this.id, s); + }), + ); + text = text.replace(secretPattern, (_, name) => secrets[name]); + } + return text.replace(/<%= (.+?) %>/g, (_, key) => { + const data = this.process.processData(this, [instance, key]); + if (data) return data; + else return ""; + }); + } + + /** + * @method processData() + * return the current value requested for the given data key. + * @param {obj} instance + * @return {mixed} | null + */ + processData(instance, key) { + const [id, param] = (key || "").split("."); + if (id != this.id) return null; + + const data = this.myState(instance); + + return data[param]; + } +};