Skip to content

Commit

Permalink
Add encryption headers and proxy endpoint for external API
Browse files Browse the repository at this point in the history
Signed-off-by: Jason Sherman <[email protected]>
  • Loading branch information
usingtechnology committed May 21, 2024
1 parent 7bcb9cd commit e6c0461
Show file tree
Hide file tree
Showing 12 changed files with 249 additions and 2 deletions.
6 changes: 5 additions & 1 deletion app/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,18 @@
"issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
"audience": "chefs-frontend-localhost-5300",
"maxTokenAge": "300"
},
},
"logLevel": "http",
"port": "8080",
"rateLimit": {
"public": {
"windowMs": "60000",
"max": "120"
}
},
"encryption": {
"proxy": "352f7c24819086bf3df5a38c1a40586045f73e0007440c9d27d59ee8560e3fe7",
"db": "728160b156ad4fd97f8fe6c5c2d23d8b543acc9d04e5002ae652a10285ff9fe4"
}
},
"serviceClient": {
Expand Down
19 changes: 19 additions & 0 deletions app/frontend/src/components/designer/FormViewer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,9 @@ export default {
},
},
async mounted() {
// load up headers for any External API calls
// from components.
await this.setProxyHeaders();
if (this.submissionId && this.isDuplicate) {
// Run when make new submission from existing one called. Get the
// published version of form, and then get the submission data.
Expand Down Expand Up @@ -308,6 +311,22 @@ export default {
this.loadingSubmission = false;
}
},
async setProxyHeaders() {
try {
let response = await formService.getProxyHeaders({
formId: this.formId,
versionId: this.versionId,
submissionId: this.submissionId,
});
// error checking for response
sessionStorage.setItem(
'X-CHEFS-PROXY-DATA',
response.data['X-CHEFS-PROXY-DATA']
);
} catch (error) {
// need error handling
}
},
// Get the form definition/schema
async getFormSchema() {
try {
Expand Down
10 changes: 10 additions & 0 deletions app/frontend/src/services/formService.js
Original file line number Diff line number Diff line change
Expand Up @@ -637,4 +637,14 @@ export default {
documentTemplateList(formId) {
return appAxios().get(`${ApiRoutes.FORMS}/${formId}/documentTemplates`);
},

/**
* @function getProxyHeaders
* Get encrypted header for calling CHEFS proxy.
* @param {Object} data An object containing formId, versionId, submissionId
* @returns {Promise} An axios response
*/
getProxyHeaders(data) {
return appAxios().post(`${ApiRoutes.PROXY}/headers`, data);
},
};
1 change: 1 addition & 0 deletions app/frontend/src/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const ApiRoutes = Object.freeze({
FILES: '/files',
UTILS: '/utils',
FILES_API_ACCESS: '/filesApiAccess',
PROXY: '/proxy',
});

/** Roles a user can have on a form. These are defined in the DB and sent from the API */
Expand Down
22 changes: 22 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"bytes": "^3.1.2",
"compression": "^1.7.4",
"config": "^3.3.9",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-basic-auth": "^1.2.1",
"express-rate-limit": "^7.2.0",
Expand Down
84 changes: 84 additions & 0 deletions app/src/components/encryption.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
const crypto = require('crypto');

const ENCRYPTION_TYPES = {
AES_256_GCM: 'aes-256-gcm',
};

class Encryption {
// eslint-disable-next-line no-unused-vars
encrypt(payload, masterkey) {
throw new Error('encrypt must be overridden.');
}
// eslint-disable-next-line no-unused-vars
decrypt(encdata, masterkey) {
throw new Error('decrypt must be overridden.');
}
}

class Aes256Gcm extends Encryption {
//
// For a masterkey:
// we want a sha256 hash: 256 bits/32 bytes/64 characters
// to generate:
// crypto.createHash('sha256').update("sometext").digest('hex');
//
encrypt(payload, masterkey) {
// random initialization vector
const iv = crypto.randomBytes(16);

// random salt
const salt = crypto.randomBytes(64);

// derive encryption key: 32 byte key length
// in assumption the masterkey is a cryptographic and NOT a password there is no need for
// a large number of iterations. It may can replaced by HKDF
// the value of 2145 is randomly chosen!
const key = crypto.pbkdf2Sync(masterkey, salt, 2145, 32, 'sha512');

// AES 256 GCM Mode
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);

// encrypt the given text
const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);

// extract the auth tag
const tag = cipher.getAuthTag();

// generate output
return Buffer.concat([salt, iv, tag, encrypted]).toString('base64');
}
decrypt(encdata, masterkey) {
// base64 decoding
const bData = Buffer.from(encdata, 'base64');

// convert data to buffers
const salt = bData.subarray(0, 64);
const iv = bData.subarray(64, 80);
const tag = bData.subarray(80, 96);
const payload = bData.subarray(96);

// derive key using; 32 byte key length
const key = crypto.pbkdf2Sync(masterkey, salt, 2145, 32, 'sha512');

// AES 256 GCM Mode
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);

// encrypt the given text
const decrypted = decipher.update(payload, 'binary', 'utf8') + decipher.final('utf8');

return decrypted;
}
}

module.exports = {
createEncryption: (type) => {
switch (type) {
case ENCRYPTION_TYPES.AES_256_GCM:
return new Aes256Gcm();
default:
throw new Error('Invalid encryption type');
}
},
ENCRYPTION_TYPES: Object.freeze(ENCRYPTION_TYPES),
};
33 changes: 33 additions & 0 deletions app/src/forms/proxy/controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const service = require('./service');
const jwtService = require('../../components/jwtService');

module.exports = {
generateProxyHeaders: async (req, res, next) => {
try {
const response = await service.generateProxyHeaders(req.body, req.currentUser, jwtService.getBearerToken(req));
res.status(200).json(response);
} catch (error) {
next(error);
}
},
callExternalApi: async (req, res, next) => {
try {
const headers = await service.readProxyHeaders(req.headers);
// read external api config
// prepare external api call
//
res.status(200).json([
{
name: headers['username'],
abbreviation: 'USER',
},
{
name: headers['email'],
abbreviation: 'EMAIL',
},
]);
} catch (error) {
next(error);
}
},
};
6 changes: 6 additions & 0 deletions app/src/forms/proxy/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const routes = require('./routes');
const setupMount = require('../common/utils').setupMount;

module.exports.mount = (app) => {
return setupMount('proxy', app, routes);
};
21 changes: 21 additions & 0 deletions app/src/forms/proxy/routes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
const cors = require('cors');

const { currentUser } = require('../auth/middleware/userAccess');
const controller = require('./controller');

const routes = require('express').Router();

// need to allow cors for OPTIONS call
// formio component will call OPTIONS pre-flight
routes.options('/external', cors());

// called with encrypted headers, no current user!!!
routes.get('/external', cors(), async (_req, res, next) => {
await controller.callExternalApi(_req, res, next);
});

routes.post('/headers', currentUser, async (_req, res, next) => {
await controller.generateProxyHeaders(_req, res, next);
});

module.exports = routes;
44 changes: 44 additions & 0 deletions app/src/forms/proxy/service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const config = require('config');
const { createEncryption, ENCRYPTION_TYPES } = require('../../components/encryption');

const encryptionKey = config.get('server.encryption.proxy');
const encryption = createEncryption(ENCRYPTION_TYPES.AES_256_GCM);

const service = {
generateProxyHeaders: async (payload, currentUser, token) => {
const headerData = {
formId: payload['formId'],
versionId: payload['versionId'],
submissionId: payload['submissionId'],
userId: currentUser.idpUserId,
username: currentUser.username,
firstName: currentUser.firstName,
lastName: currentUser.lastName,
fullName: currentUser.fullName,
email: currentUser.email,
idp: currentUser.idp,
token: token,
};
const encryptedHeaderData = encryption.encrypt(headerData, encryptionKey);
return {
'X-CHEFS-PROXY-DATA': encryptedHeaderData,
};
},
readProxyHeaders: async (headers) => {
const encryptedHeaderData = headers['X-CHEFS-PROXY-DATA'] || headers['x-chefs-proxy-data'];
if (encryptedHeaderData) {
//error check that we can decrypt it and it contains expected data...
try {
const decryptedHeaderData = encryption.decrypt(encryptedHeaderData, encryptionKey);
const data = JSON.parse(decryptedHeaderData);
return data;
} catch (error) {
throw Error(`Could not decrypt proxy headers: ${error.message}`);
}
} else {
throw Error('Proxy headers not found');
}
},
};

module.exports = service;
4 changes: 3 additions & 1 deletion app/src/routes/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const user = require('../forms/user');
const submission = require('../forms/submission');
const utils = require('../forms/utils');
const index = require('../forms/public');
const proxy = require('../forms/proxy');

admin.mount(router);
const bcaddress = bcgeoaddress.mount(router);
Expand All @@ -27,6 +28,7 @@ const userPath = user.mount(router);
const submissionPath = submission.mount(router);
const utilsPath = utils.mount(router);
const publicPath = index.mount(router);
const proxyPath = proxy.mount(router);

const getSpec = () => {
const rawSpec = fs.readFileSync(path.join(__dirname, '../docs/v1.api-spec.yaml'), 'utf8');
Expand All @@ -39,7 +41,7 @@ const getSpec = () => {
// Base v1 Responder
router.get('/', (_req, res) => {
res.status(200).json({
endpoints: ['/docs', filePath, formPath, permissionPath, rbacPath, rolePath, submissionPath, userPath, bcaddress, publicPath, utilsPath],
endpoints: ['/docs', proxyPath, filePath, formPath, permissionPath, rbacPath, rolePath, submissionPath, userPath, bcaddress, publicPath, utilsPath],
});
});

Expand Down

0 comments on commit e6c0461

Please sign in to comment.